loopengine 0.1.80 → 0.1.81

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
@@ -172,13 +172,15 @@ export const config: AgentConfig = {
172
172
  }
173
173
  ```
174
174
 
175
- Or skip the code: in the console, the Permissions tab's **Where approval
176
- requests and questions are sent** section sets a
175
+ Or skip the code: in the console, the agent's **Messaging** tab sets a
177
176
  webhook, Slack or Lark target for one agent (saved to
178
- `agents/<name>/notifier.yml`, with its secret in the agent's own `.env`),
179
- sends a test, shows recent deliveries, and lists every decision still
180
- waiting with Approve/Deny buttons. The tab warns you when an agent has
181
- `ask` rules but nobody would be told.
177
+ `agents/<name>/messaging.yml`, with its secret in the agent's own `.env`;
178
+ an older `notifier.yml` is still read), sends a test and shows recent
179
+ deliveries. With Slack, people can also reply in a request's thread to
180
+ ask the agent about it, and it answers there (**Answer replies in a
181
+ request's thread**, on by default). The Permissions tab lists every
182
+ decision still waiting with Approve/Deny buttons, and warns you when an
183
+ agent has `ask` rules but nobody would be told.
182
184
 
183
185
  Every notification links to a **decision page**: a signed, single-use
184
186
  link (`/decide/<token>`, valid 7 days) where whoever got the message can
@@ -195,6 +195,7 @@ export type HttpNotifierConfig = {
195
195
  channelId: string;
196
196
  publicUrl?: string;
197
197
  buttons?: boolean;
198
+ replyHint?: boolean;
198
199
  };
199
200
  events: HttpNotifierEvent[];
200
201
  } | {
@@ -8,7 +8,7 @@ interface ResolvedHttpNotifier {
8
8
  onRunFinish?: NonNullable<AgentConfig['onRunFinish']>;
9
9
  }
10
10
  /** Where an agent's durable approvals/questions go, for the console:
11
- * `code` (AgentConfig.httpNotifier), `file` (agents/<name>/notifier.yml),
11
+ * `code` (AgentConfig.httpNotifier), `file` (agents/<name>/messaging.yml),
12
12
  * or `none`. A file whose secret isn't set yet is reported, not used. */
13
13
  export declare function describeNotifier(config: AgentConfig): {
14
14
  source: 'code' | 'file' | 'none';
@@ -19,7 +19,7 @@ export declare function describeNotifier(config: AgentConfig): {
19
19
  error?: string;
20
20
  };
21
21
  /** AgentConfig.httpNotifier when the agent sets one in code; otherwise
22
- * its agents/<name>/notifier.yml (see core/notifier-file.ts), re-read on
22
+ * its agents/<name>/messaging.yml (see core/notifier-file.ts), re-read on
23
23
  * every call so a console edit applies to the very next turn. */
24
24
  export declare function resolveHttpNotifier(config: AgentConfig): ResolvedHttpNotifier;
25
25
  export {};
@@ -33,7 +33,7 @@ function formatEmailLifecycleSubject(event, context) {
33
33
  return event === 'run_start' ? `${context.agent} started` : `${context.agent} finished`;
34
34
  }
35
35
  /** Where an agent's durable approvals/questions go, for the console:
36
- * `code` (AgentConfig.httpNotifier), `file` (agents/<name>/notifier.yml),
36
+ * `code` (AgentConfig.httpNotifier), `file` (agents/<name>/messaging.yml),
37
37
  * or `none`. A file whose secret isn't set yet is reported, not used. */
38
38
  export function describeNotifier(config) {
39
39
  if (config.httpNotifier)
@@ -44,7 +44,7 @@ export function describeNotifier(config) {
44
44
  file = readNotifierFile(dir);
45
45
  }
46
46
  catch (err) {
47
- return { source: 'none', error: `notifier.yml is invalid: ${err instanceof Error ? err.message : String(err)}` };
47
+ return { source: 'none', error: `The messaging settings are invalid: ${err instanceof Error ? err.message : String(err)}` };
48
48
  }
49
49
  if (!file)
50
50
  return { source: 'none' };
@@ -52,7 +52,7 @@ export function describeNotifier(config) {
52
52
  return { source: missingSecret ? 'none' : 'file', channel: file.channel, events: file.events, file, missingSecret };
53
53
  }
54
54
  /** AgentConfig.httpNotifier when the agent sets one in code; otherwise
55
- * its agents/<name>/notifier.yml (see core/notifier-file.ts), re-read on
55
+ * its agents/<name>/messaging.yml (see core/notifier-file.ts), re-read on
56
56
  * every call so a console edit applies to the very next turn. */
57
57
  export function resolveHttpNotifier(config) {
58
58
  const httpNotifier = config.httpNotifier;
@@ -72,7 +72,7 @@ function resolveFromFile(config) {
72
72
  file = readNotifierFile(dir);
73
73
  }
74
74
  catch (err) {
75
- console.error(`[loopengine] ignoring agents/${config.name}/notifier.yml:`, err instanceof Error ? err.message : err);
75
+ console.error(`[loopengine] ignoring the messaging settings in agents/${config.name}/:`, err instanceof Error ? err.message : err);
76
76
  return EMPTY;
77
77
  }
78
78
  if (!file)
@@ -101,13 +101,13 @@ function buildResolved(httpNotifier, agentDir) {
101
101
  break;
102
102
  }
103
103
  case 'slack': {
104
- const { botToken, channelId, publicUrl, buttons } = httpNotifier.config;
104
+ const { botToken, channelId, publicUrl, buttons, replyHint } = httpNotifier.config;
105
105
  // One shared instance for every concern this httpNotifier actually
106
106
  // covers — same "it's Slack-specific, not approval/question/
107
107
  // lifecycle-specific" reasoning SlackNotifier's own doc
108
108
  // comment works out; cheap to construct (holds two strings, does no
109
109
  // I/O itself) so there's no reason to build one per event kind.
110
- const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons, onThread: ({ channel, ts, pendingId, kind }) => recordThread(agentDir, { app: 'slack', conversation: channel, ts, pendingId, kind }),
110
+ const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons, replyHint, onThread: ({ channel, ts, pendingId, kind }) => recordThread(agentDir, { app: 'slack', conversation: channel, ts, pendingId, kind }),
111
111
  });
112
112
  resolved = {
113
113
  approver: events.has('approval') ? slack : undefined,
@@ -13,6 +13,7 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
13
13
  private readonly channelId;
14
14
  private readonly publicUrl?;
15
15
  private readonly buttons;
16
+ private readonly replyHint;
16
17
  private readonly onThread?;
17
18
  /** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
18
19
  * wins when set) — when known, each message links to a decision page
@@ -22,14 +23,17 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
22
23
  * slack.ts); default true, false for a console-configured notifier,
23
24
  * which relies on the link instead. `onThread`: told where each
24
25
  * approval request or question landed, so a reply in its thread can be
25
- * matched to it (core/messaging/threads.ts). */
26
+ * matched to it (core/messaging/threads.ts). `replyHint`: add a line
27
+ * saying replies in the thread are answered, when they are. */
26
28
  constructor(options: {
27
29
  botToken: string;
28
30
  channelId: string;
29
31
  publicUrl?: string;
30
32
  buttons?: boolean;
33
+ replyHint?: boolean;
31
34
  onThread?: (posted: SlackPostedRequest) => void;
32
35
  });
36
+ private replyHintBlocks;
33
37
  private threadRecorder;
34
38
  private decideLine;
35
39
  requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
@@ -31,6 +31,7 @@ export class SlackNotifier {
31
31
  channelId;
32
32
  publicUrl;
33
33
  buttons;
34
+ replyHint;
34
35
  onThread;
35
36
  /** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
36
37
  * wins when set) — when known, each message links to a decision page
@@ -40,14 +41,22 @@ export class SlackNotifier {
40
41
  * slack.ts); default true, false for a console-configured notifier,
41
42
  * which relies on the link instead. `onThread`: told where each
42
43
  * approval request or question landed, so a reply in its thread can be
43
- * matched to it (core/messaging/threads.ts). */
44
+ * matched to it (core/messaging/threads.ts). `replyHint`: add a line
45
+ * saying replies in the thread are answered, when they are. */
44
46
  constructor(options) {
45
47
  this.botToken = options.botToken;
46
48
  this.channelId = options.channelId;
47
49
  this.publicUrl = options.publicUrl;
48
50
  this.buttons = options.buttons ?? true;
51
+ this.replyHint = options.replyHint ?? false;
49
52
  this.onThread = options.onThread;
50
53
  }
54
+ replyHintBlocks(kind) {
55
+ if (!this.replyHint)
56
+ return [];
57
+ const text = kind === 'approval' ? 'Reply in this thread to ask about it.' : 'Reply in this thread to ask about the question.';
58
+ return [{ type: 'context', elements: [{ type: 'mrkdwn', text }] }];
59
+ }
51
60
  threadRecorder(pendingId, kind) {
52
61
  const onThread = this.onThread;
53
62
  return onThread ? (posted) => onThread({ ...posted, pendingId, kind }) : undefined;
@@ -75,6 +84,7 @@ export class SlackNotifier {
75
84
  ],
76
85
  }]
77
86
  : []),
87
+ ...this.replyHintBlocks('approval'),
78
88
  ], this.threadRecorder(pendingId, 'approval'));
79
89
  return { pendingId };
80
90
  }
@@ -93,6 +103,7 @@ export class SlackNotifier {
93
103
  ...(this.buttons
94
104
  ? [{ type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] }]
95
105
  : []),
106
+ ...this.replyHintBlocks('question'),
96
107
  ], this.threadRecorder(pendingId, 'question'));
97
108
  return { pendingId };
98
109
  }
@@ -1,3 +1,4 @@
1
+ import { toSlackMrkdwn } from './format.js';
1
2
  /** Reads an Events API body. Only people's new messages come through;
2
3
  * edits, deletions, joins and every bot's messages (this agent's own
3
4
  * included) are `ignore`. */
@@ -49,7 +50,7 @@ export function createSlackConnector(botToken) {
49
50
  const res = await fetch('https://slack.com/api/chat.postMessage', {
50
51
  method: 'POST',
51
52
  headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
52
- body: JSON.stringify({ channel: conversation, thread_ts: threadTs, text }),
53
+ body: JSON.stringify({ channel: conversation, thread_ts: threadTs, text: toSlackMrkdwn(text) }),
53
54
  });
54
55
  const body = (await res.json());
55
56
  if (!body.ok)
@@ -0,0 +1 @@
1
+ export declare function toSlackMrkdwn(markdown: string): string;
@@ -0,0 +1,34 @@
1
+ // Models write Markdown even when asked for Slack's own format, and Slack
2
+ // shows Markdown's **bold**, [links](url) and # headings as literal
3
+ // characters. This turns the common cases into Slack mrkdwn before a
4
+ // reply is posted; code (```blocks``` and `spans`) is left as written.
5
+ const CODE = /(```[\s\S]*?```|`[^`\n]*`)/;
6
+ export function toSlackMrkdwn(markdown) {
7
+ return markdown
8
+ .split(CODE)
9
+ .map((part, i) => (i % 2 === 1 ? part : convertProse(part)))
10
+ .join('');
11
+ }
12
+ function convertProse(text) {
13
+ const bold = [];
14
+ const keepBold = (inner) => {
15
+ bold.push(inner);
16
+ return `\u0000${bold.length - 1}\u0000`;
17
+ };
18
+ let out = text
19
+ // # Heading -> *Heading*
20
+ .replace(/^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, (_m, title) => keepBold(title))
21
+ // - item / * item -> • item
22
+ .replace(/^([ \t]*)[-*+][ \t]+/gm, '$1• ')
23
+ // **bold** / __bold__ -> *bold*
24
+ .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, (_m, inner) => keepBold(inner))
25
+ .replace(/__(?=\S)([\s\S]*?\S)__/g, (_m, inner) => keepBold(inner))
26
+ // *italic* -> _italic_ (what's left of single asterisks)
27
+ .replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1_$2_')
28
+ // ~~strike~~ -> ~strike~
29
+ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '~$1~')
30
+ // [text](url) -> <url|text>
31
+ .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<$2|$1>');
32
+ out = out.replace(/\u0000(\d+)\u0000/g, (_m, n) => `*${bold[Number(n)]}*`);
33
+ return out;
34
+ }
@@ -1,3 +1,4 @@
1
1
  export { createSlackConnector, parseSlackEvent, type SlackEvent } from './events.js';
2
+ export { toSlackMrkdwn } from './format.js';
2
3
  export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, slackOutcomeText, verifySlackRequest, type SlackInteraction, } from './interactions.js';
3
4
  export { listSlackChannels, slackAppManifest, type SlackChannel } from './setup.js';
@@ -1,8 +1,9 @@
1
1
  // The Slack connector (core/messaging/connector.ts), one file per part:
2
- // thread replies (./events.ts), buttons and the signing check
2
+ // thread replies (./events.ts) and their formatting (./format.ts), buttons and the signing check
3
3
  // (./interactions.ts), and the console's app setup (./setup.ts).
4
4
  // Posting requests is still SlackNotifier's job
5
5
  // (core/http-notify-triggers/slack.ts).
6
6
  export { createSlackConnector, parseSlackEvent } from './events.js';
7
+ export { toSlackMrkdwn } from './format.js';
7
8
  export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, slackOutcomeText, verifySlackRequest, } from './interactions.js';
8
9
  export { listSlackChannels, slackAppManifest } from './setup.js';
@@ -14,6 +14,9 @@ export interface NotifierFile {
14
14
  * remembered from the console's own address when saving.
15
15
  * LOOPENGINE_PUBLIC_URL, when set, wins over it. */
16
16
  publicUrl?: string;
17
+ /** slack: answer replies in a request's thread (core/messaging/).
18
+ * Unset counts as on. */
19
+ conversations?: boolean;
17
20
  }
18
21
  /** The env var each channel's secret is read from, per agent. */
19
22
  export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
@@ -23,14 +26,18 @@ export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
23
26
  export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
24
27
  export declare class NotifierFileError extends Error {
25
28
  }
29
+ /** The older single-connection file, still read when messaging.yml is absent. */
26
30
  export declare function notifierFilePath(dir: string): string;
31
+ export declare function messagingFilePath(dir: string): string;
27
32
  /** Throws NotifierFileError naming the first problem. */
28
33
  export declare function validateNotifierFile(input: unknown): NotifierFile;
29
- /** undefined when the agent has no notifier.yml. */
34
+ /** The agent's connection: messaging.yml's first, else the older
35
+ * notifier.yml's; undefined when it has neither. */
30
36
  export declare function readNotifierFile(dir: string): NotifierFile | undefined;
37
+ /** Writes messaging.yml (and drops an older notifier.yml it replaces). */
31
38
  export declare function writeNotifierFile(dir: string, input: unknown): NotifierFile;
32
39
  export declare function removeNotifierFile(dir: string): void;
33
- /** The HttpNotifierConfig a notifier.yml describes, with its secret read
40
+ /** The HttpNotifierConfig a connection describes, with its secret read
34
41
  * from `env` — or which secret is missing, so nothing is sent unsigned
35
42
  * or unauthenticated. */
36
43
  export declare function notifierConfigFromFile(file: NotifierFile, env: AgentEnv): {
@@ -1,7 +1,21 @@
1
- // agents/<name>/notifier.yml — where an agent's "ask" approvals and
2
- // questions go when nobody is watching a live chat, set from the console
3
- // (Permissions → "Where approval requests and questions are sent") instead of in code. Only consulted when the
4
- // agent's own AgentConfig.httpNotifier is unset; code always wins.
1
+ // agents/<name>/messaging.yml — where an agent's "ask" approvals and
2
+ // questions go when nobody is watching a live chat, and whether it
3
+ // answers replies there, set from the console's Messaging tab instead of
4
+ // in code. Only consulted when the agent's own AgentConfig.httpNotifier
5
+ // is unset; code always wins.
6
+ //
7
+ // The file holds a list of connections, though only the first is used
8
+ // for now:
9
+ //
10
+ // connections:
11
+ // - app: slack
12
+ // channelId: C0123456
13
+ // events: [approval, question]
14
+ // conversations: true
15
+ //
16
+ // An older agents/<name>/notifier.yml (one connection, `channel:` for
17
+ // `app:`) is still read; saving from the console replaces it with
18
+ // messaging.yml.
5
19
  //
6
20
  // The file holds only non-secret settings. The one secret each channel
7
21
  // needs (a webhook signing secret, a Slack bot token, a Lark app secret)
@@ -25,9 +39,13 @@ const CHANNELS = ['webhook', 'slack', 'lark'];
25
39
  const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
26
40
  export class NotifierFileError extends Error {
27
41
  }
42
+ /** The older single-connection file, still read when messaging.yml is absent. */
28
43
  export function notifierFilePath(dir) {
29
44
  return join(dir, 'notifier.yml');
30
45
  }
46
+ export function messagingFilePath(dir) {
47
+ return join(dir, 'messaging.yml');
48
+ }
31
49
  /** Throws NotifierFileError naming the first problem. */
32
50
  export function validateNotifierFile(input) {
33
51
  if (!input || typeof input !== 'object')
@@ -42,6 +60,12 @@ export function validateNotifierFile(input) {
42
60
  }
43
61
  const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
44
62
  const file = { channel, events: events };
63
+ if (raw.conversations !== undefined) {
64
+ if (typeof raw.conversations !== 'boolean')
65
+ throw new NotifierFileError('conversations must be true or false');
66
+ if (channel === 'slack')
67
+ file.conversations = raw.conversations;
68
+ }
45
69
  const publicUrl = str('publicUrl');
46
70
  if (publicUrl) {
47
71
  if (!/^https?:\/\/[^/]+/.test(publicUrl))
@@ -70,24 +94,43 @@ export function validateNotifierFile(input) {
70
94
  }
71
95
  return file;
72
96
  }
73
- /** undefined when the agent has no notifier.yml. */
97
+ /** The agent's connection: messaging.yml's first, else the older
98
+ * notifier.yml's; undefined when it has neither. */
74
99
  export function readNotifierFile(dir) {
100
+ const messagingPath = messagingFilePath(dir);
101
+ if (existsSync(messagingPath)) {
102
+ const parsed = parseYaml(readFileSync(messagingPath, 'utf8'));
103
+ if (parsed?.connections !== undefined && !Array.isArray(parsed.connections))
104
+ throw new NotifierFileError('connections must be a list');
105
+ const first = parsed?.connections?.[0];
106
+ if (first === undefined)
107
+ return undefined;
108
+ if (!first || typeof first !== 'object')
109
+ throw new NotifierFileError('each connection must be an object');
110
+ const { app, ...rest } = first;
111
+ return validateNotifierFile({ channel: app, ...rest });
112
+ }
75
113
  const path = notifierFilePath(dir);
76
114
  if (!existsSync(path))
77
115
  return undefined;
78
116
  return validateNotifierFile(parseYaml(readFileSync(path, 'utf8')));
79
117
  }
118
+ /** Writes messaging.yml (and drops an older notifier.yml it replaces). */
80
119
  export function writeNotifierFile(dir, input) {
81
120
  const file = validateNotifierFile(input);
82
- const header = '# Where this agent sends approval requests and questions when it is called over HTTP.\n' +
83
- `# Edited from the console (Permissions tab). The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[file.channel]}.\n`;
84
- writeFileSync(notifierFilePath(dir), header + stringifyYaml(file));
121
+ const { channel, ...rest } = file;
122
+ const header = '# Where this agent sends approval requests and questions when it is called over HTTP,\n' +
123
+ '# and whether it answers replies there. Edited from the console (Messaging tab).\n' +
124
+ `# The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[channel]}.\n`;
125
+ writeFileSync(messagingFilePath(dir), header + stringifyYaml({ connections: [{ app: channel, ...rest }] }));
126
+ rmSync(notifierFilePath(dir), { force: true });
85
127
  return file;
86
128
  }
87
129
  export function removeNotifierFile(dir) {
130
+ rmSync(messagingFilePath(dir), { force: true });
88
131
  rmSync(notifierFilePath(dir), { force: true });
89
132
  }
90
- /** The HttpNotifierConfig a notifier.yml describes, with its secret read
133
+ /** The HttpNotifierConfig a connection describes, with its secret read
91
134
  * from `env` — or which secret is missing, so nothing is sent unsigned
92
135
  * or unauthenticated. */
93
136
  export function notifierConfigFromFile(file, env) {
@@ -105,7 +148,14 @@ export function notifierConfigFromFile(file, env) {
105
148
  return {
106
149
  config: {
107
150
  channel: 'slack',
108
- config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)) },
151
+ config: {
152
+ botToken: secret,
153
+ channelId: file.channelId,
154
+ publicUrl: file.publicUrl,
155
+ buttons: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)),
156
+ // Says replies in the thread are answered, when they are.
157
+ replyHint: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)) && file.conversations !== false && process.env.LOOPENGINE_PLAN !== 'free',
158
+ },
109
159
  events: file.events,
110
160
  },
111
161
  };
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackIn
19
19
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
20
20
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
21
21
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
22
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
22
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
23
23
  export type * from './core/loop-events.js';
24
24
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
25
25
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackIn
20
20
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
21
21
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
22
22
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
23
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
23
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
24
24
  // The framework-agnostic browser client for adapters/http.ts's own wire
25
25
  // protocol (see client.ts's own header comment) — this, not a deep
26
26
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -898,8 +898,13 @@ ${consoleShellScript}
898
898
 
899
899
  function renderActauthTabPlaceholder() {
900
900
  return '<div id="pendingContent"></div>' +
901
- '<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading&hellip;</p></div></section>' +
902
- '<div id="approvalsContent"><p class="hint">Loading&hellip;</p></div>';
901
+ '<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading&hellip;</p></div></section>';
902
+ }
903
+
904
+ // The Messaging tab: where approval requests and questions go, and
905
+ // whether the agent answers replies there. Filled by loadApprovalsSection.
906
+ function renderMessagingTabPlaceholder() {
907
+ return '<div id="approvalsContent"><p class="hint">Loading&hellip;</p></div>';
903
908
  }
904
909
 
905
910
  function renderActauthRuleRow(r) {
@@ -963,11 +968,12 @@ ${consoleShellScript}
963
968
  '</form></section>';
964
969
  }
965
970
 
966
- // ---- Permissions → "Where approval requests and questions are sent": where "ask" decisions go when nobody is
967
- // in Chat (web/notifier-admin.ts behind GET/PUT/DELETE .../notifier),
968
- // the decisions still waiting (GET .../pending), and recent webhook
969
- // deliveries. Reloaded every time the tab opens, so the waiting list is
970
- // current. ----
971
+ // ---- Messaging tab: where "ask" decisions go when nobody is in Chat,
972
+ // and whether the agent answers replies there (web/notifier-admin.ts
973
+ // behind GET/PUT/DELETE .../notifier), plus recent deliveries. The
974
+ // decisions still waiting (GET .../pending) show at the top of
975
+ // Permissions. Reloaded every time either tab opens, so the waiting
976
+ // list is current. ----
971
977
 
972
978
  var lastNotifier = null;
973
979
  var NOTIFIER_EVENTS = [
@@ -1006,6 +1012,19 @@ ${consoleShellScript}
1006
1012
  return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a webhook, Slack channel or Lark chat below.';
1007
1013
  }
1008
1014
 
1015
+ // Slack only: whether replies in a request's thread are answered. On
1016
+ // unless turned off; locked on LoopEngine Cloud's free plan.
1017
+ function renderConversationsField(n, f) {
1018
+ var free = n.plan === 'free';
1019
+ var on = !free && f.conversations !== false;
1020
+ return '<div class="body-field" style="margin:4px 0 0">' +
1021
+ '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="conversations" id="slackConversations"' + (on ? ' checked' : '') + (free ? ' disabled' : '') + '> ' +
1022
+ 'Answer replies in a request&#39;s thread' + (free ? ' <span class="le-pill accent">Pro</span>' : '') + '</label>' +
1023
+ '<p class="hint" style="margin:2px 0 0">' + (free
1024
+ ? 'Talking a request through in Slack needs LoopEngine Pro. Approve and Deny buttons work on every plan.'
1025
+ : 'People can ask the agent about a request in its thread, and it answers there. It can&#39;t approve or change anything from the thread yet: deciding still uses the buttons.') + '</p></div>';
1026
+ }
1027
+
1009
1028
  function renderNotifierForm(n) {
1010
1029
  var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
1011
1030
  var events = f.events || [];
@@ -1046,6 +1065,7 @@ ${consoleShellScript}
1046
1065
  (n.slack && n.slack.signingSecretSet ? 'set &mdash; Approve/Deny buttons are on' : 'adds Approve/Deny buttons inside Slack; without it, messages carry the approval link') + ')</span>' +
1047
1066
  '<input type="password" name="signingSecret" autocomplete="off"></label>' +
1048
1067
  '<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>' +
1068
+ renderConversationsField(n, f) +
1049
1069
  '<p class="hint" style="margin:0" id="slackEventsNote"></p>') +
1050
1070
  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')) +
1051
1071
  '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
@@ -1091,32 +1111,29 @@ ${consoleShellScript}
1091
1111
  if (tools.length && n && n.source === 'none') {
1092
1112
  html += '<div class="approvals-nudge"><strong>Approval requests aren&#39;t sent anywhere yet.</strong> ' +
1093
1113
  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. ' +
1094
- '<a href="#approvalsSetup" class="approvals-nudge-link">Choose where they go</a></div>';
1114
+ '<a href="?tab=messaging" data-tab="messaging">Choose where they go</a></div>';
1095
1115
  }
1096
1116
  if (pending.length) html += '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
1097
1117
  return html;
1098
1118
  }
1099
1119
 
1100
1120
  function renderApprovalsHtml(n) {
1101
- return '<section id="approvalsSetup"><h3>Where approval requests and questions are sent</h3>' +
1102
- '<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>' +
1121
+ return '<p class="muted" style="margin:0 0 20px">Connect a messaging app, so this agent can reach people when nobody is in Chat. When an app calls it over HTTP or a scheduled task runs, and a tool call needs approval or the agent asks a question, the request is sent here. In Chat it shows up in the conversation instead.</p>' +
1122
+ '<section id="approvalsSetup"><h3>Connection</h3>' +
1103
1123
  '<p class="muted" style="margin:0 0 12px">Currently: ' + renderNotifierStatus(n) + '</p>' +
1104
1124
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
1105
1125
  (n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
1126
+ '<section><h3>Deciding in Slack <span class="le-pill neutral">coming soon</span></h3>' +
1127
+ '<ul class="muted" style="margin:0;padding-left:18px">' +
1128
+ '<li><strong>Approvers</strong>: link Slack people to LoopEngine users, so they can approve, deny or change a request by replying in its thread.</li>' +
1129
+ '<li><strong>Confirm-required tools</strong>: tools, such as ones that move money or delete data, that always need a Confirm click, never just a typed yes.</li>' +
1130
+ '</ul></section>' +
1106
1131
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
1107
1132
  }
1108
1133
 
1109
1134
  function wirePendingHandlers(name) {
1110
1135
  var content = detail.querySelector('#pendingContent');
1111
1136
  if (!content) return;
1112
- var nudgeLink = content.querySelector('.approvals-nudge-link');
1113
- if (nudgeLink) {
1114
- nudgeLink.addEventListener('click', function (ev) {
1115
- ev.preventDefault();
1116
- var target = detail.querySelector('#approvalsSetup');
1117
- if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1118
- });
1119
- }
1120
1137
  var copyBtns = content.querySelectorAll('.pending-copy');
1121
1138
  for (var c = 0; c < copyBtns.length; c++) {
1122
1139
  copyBtns[c].addEventListener('click', function (ev) {
@@ -1231,6 +1248,10 @@ ${consoleShellScript}
1231
1248
  var secret = data.get('secret_' + channel);
1232
1249
  if (secret) body.secret = secret;
1233
1250
  if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
1251
+ var conversationsBox = form.querySelector('#slackConversations');
1252
+ // A locked switch (free plan) keeps whatever was saved before.
1253
+ if (channel === 'slack' && conversationsBox && !conversationsBox.disabled) body.conversations = conversationsBox.checked;
1254
+ else if (channel === 'slack' && lastNotifier && lastNotifier.file && lastNotifier.file.conversations !== undefined) body.conversations = lastNotifier.file.conversations;
1234
1255
  result.textContent = 'Saving…';
1235
1256
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
1236
1257
  method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
@@ -1338,7 +1359,7 @@ ${consoleShellScript}
1338
1359
  var list = tasks.length
1339
1360
  ? '<div class="le-table-wrap"><table><thead><tr><th>Task</th><th>When</th><th>Status</th><th>Last run</th><th></th></tr></thead><tbody>' + tasks.map(renderScheduleRow).join('') + '</tbody></table></div>'
1340
1361
  : '<p class="hint">Nothing scheduled yet.</p>';
1341
- return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the Permissions tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
1362
+ return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the Messaging tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
1342
1363
  '<section><h3>Scheduled (' + active.length + ' active)</h3>' + list + '</section>' +
1343
1364
  '<section><h3>New schedule</h3>' +
1344
1365
  '<form class="add-source" id="scheduleForm" style="max-width:560px">' +
@@ -1351,7 +1372,7 @@ ${consoleShellScript}
1351
1372
  '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
1352
1373
  '</div>' +
1353
1374
  '<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
1354
- '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent&#39;s notifier (set in the Permissions tab)</label>' +
1375
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent&#39;s messaging connection (set in the Messaging tab)</label>' +
1355
1376
  '<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
1356
1377
  '<div id="scheduleResult" class="hint"></div>' +
1357
1378
  '</form></section>';
@@ -2898,7 +2919,7 @@ ${consoleShellScript}
2898
2919
 
2899
2920
  // ---- Tab switching + agent selection ----
2900
2921
 
2901
- var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'schedules', 'env'];
2922
+ var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'messaging', 'schedules', 'env'];
2902
2923
 
2903
2924
  function switchTab(tab) {
2904
2925
  if (CONFIG_TABS.indexOf(tab) === -1) tab = 'overview';
@@ -2919,7 +2940,7 @@ ${consoleShellScript}
2919
2940
  if (tab === 'actauth' && actauthLoadedFor !== currentName) {
2920
2941
  loadActauthTab(currentName);
2921
2942
  }
2922
- if (tab === 'actauth') loadApprovalsSection(currentName);
2943
+ if (tab === 'actauth' || tab === 'messaging') loadApprovalsSection(currentName);
2923
2944
  if (tab === 'env' && envLoadedFor !== currentName) {
2924
2945
  loadEnvTab(currentName);
2925
2946
  }
@@ -3021,6 +3042,7 @@ ${consoleShellScript}
3021
3042
  '<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
3022
3043
  '<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
3023
3044
  '<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
3045
+ '<div class="tab-panel" data-tab-panel="messaging">' + renderMessagingTabPlaceholder() + '</div>' +
3024
3046
  '<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
3025
3047
  '<div class="tab-panel" data-tab-panel="abilities">' + renderAbilitiesTabPlaceholder() + '</div>' +
3026
3048
  '<div class="tab-panel" data-tab-panel="schedules">' + renderSchedulesTabPlaceholder() + '</div>' +
@@ -44,7 +44,7 @@ export const consoleShellScript = `<script>
44
44
  var toSet = {};
45
45
  var TABS = [
46
46
  ['chat', 'Chat'], ['overview', 'Overview'], ['abilities', 'Abilities'], ['tools', 'Tools'],
47
- ['skills', 'Skills'], ['actauth', 'Permissions'], ['schedules', 'Schedules'], ['env', 'Environment']
47
+ ['skills', 'Skills'], ['actauth', 'Permissions'], ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment']
48
48
  ];
49
49
 
50
50
  function esc(s) {
@@ -15,6 +15,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
15
15
  source: string;
16
16
  defaultWebhookUrl: string | null;
17
17
  publicUrl: string | null;
18
+ plan: string | null;
18
19
  secretVars: Record<NotifierFileChannel, string>;
19
20
  secretsSet: {
20
21
  [k: string]: boolean;
@@ -23,6 +24,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
23
24
  signingSecretVar: string;
24
25
  signingSecretSet: boolean;
25
26
  interactivityPath: string;
27
+ eventsPath: string;
26
28
  manifestTemplate: Record<string, unknown>;
27
29
  };
28
30
  deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
@@ -32,7 +34,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
32
34
  missingSecret?: string;
33
35
  error?: string;
34
36
  };
35
- /** Writes agents/<name>/notifier.yml from the console form and, when the
37
+ /** Writes agents/<name>/messaging.yml from the console form and, when the
36
38
  * form includes one, the channel's secret into the agent's own env.
37
39
  * Throws NotifierFileError for an invalid form. */
38
40
  export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
@@ -44,10 +46,14 @@ export declare function slackCredentialsFor(config: AgentConfig): {
44
46
  botToken?: string;
45
47
  signingSecret?: string;
46
48
  };
49
+ /** Whether replies in a Slack request's thread are answered: on unless
50
+ * the Messaging tab turned Conversations off. An agent whose Slack
51
+ * notifier is set in code always answers. */
52
+ export declare function slackConversationsOn(config: AgentConfig): boolean;
47
53
  /** Channels for the console's picker, with the token being typed in or,
48
54
  * when none is given, the one already saved. */
49
55
  export declare function listAgentSlackChannels(config: AgentConfig, botToken?: string): Promise<SlackChannel[]>;
50
- /** Removes notifier.yml; the stored secret is left alone, so turning the
56
+ /** Removes the messaging settings; the stored secret is left alone, so turning the
51
57
  * notifier back on doesn't need it re-entered. */
52
58
  export declare function removeAgentNotifier(config: AgentConfig): void;
53
59
  /** Posts a plain announcement through the agent's notifier — a scheduled
@@ -19,6 +19,9 @@ export function describeAgentNotifier(config, defaultWebhook) {
19
19
  source,
20
20
  defaultWebhookUrl: defaultWebhook?.webhookUrl ?? null,
21
21
  publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
22
+ // LOOPENGINE_PLAN ('free' on LoopEngine Cloud's free plan): the
23
+ // console locks Conversations on free. null when self-hosted.
24
+ plan: process.env.LOOPENGINE_PLAN ?? null,
22
25
  secretVars: NOTIFIER_SECRET_VARS,
23
26
  secretsSet,
24
27
  slack: {
@@ -26,6 +29,8 @@ export function describeAgentNotifier(config, defaultWebhook) {
26
29
  signingSecretSet: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) !== undefined,
27
30
  // Where the Slack app's Interactivity Request URL should point.
28
31
  interactivityPath: `/hooks/slack/${encodeURIComponent(config.name)}`,
32
+ // Where replies in a request's thread arrive (core/messaging/).
33
+ eventsPath: `/hooks/slack/${encodeURIComponent(config.name)}/events`,
29
34
  // The console swaps __INTERACTIVITY_URL__ for the link address +
30
35
  // interactivityPath as you type.
31
36
  manifestTemplate: slackAppManifest({ agentName: config.name, interactivityUrl: '__INTERACTIVITY_URL__' }),
@@ -33,7 +38,7 @@ export function describeAgentNotifier(config, defaultWebhook) {
33
38
  deliveries: listDeliveries(config.name),
34
39
  };
35
40
  }
36
- /** Writes agents/<name>/notifier.yml from the console form and, when the
41
+ /** Writes agents/<name>/messaging.yml from the console form and, when the
37
42
  * form includes one, the channel's secret into the agent's own env.
38
43
  * Throws NotifierFileError for an invalid form. */
39
44
  export async function saveAgentNotifier(config, body) {
@@ -54,6 +59,19 @@ export function slackCredentialsFor(config) {
54
59
  const botToken = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.slack);
55
60
  return { botToken, signingSecret: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) };
56
61
  }
62
+ /** Whether replies in a Slack request's thread are answered: on unless
63
+ * the Messaging tab turned Conversations off. An agent whose Slack
64
+ * notifier is set in code always answers. */
65
+ export function slackConversationsOn(config) {
66
+ if (config.httpNotifier)
67
+ return config.httpNotifier.channel === 'slack';
68
+ try {
69
+ return readNotifierFile(agentDirFor(config))?.conversations !== false;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
57
75
  /** Channels for the console's picker, with the token being typed in or,
58
76
  * when none is given, the one already saved. */
59
77
  export async function listAgentSlackChannels(config, botToken) {
@@ -62,13 +80,13 @@ export async function listAgentSlackChannels(config, botToken) {
62
80
  throw new Error('Enter the bot token first');
63
81
  return listSlackChannels(token);
64
82
  }
65
- /** Removes notifier.yml; the stored secret is left alone, so turning the
83
+ /** Removes the messaging settings; the stored secret is left alone, so turning the
66
84
  * notifier back on doesn't need it re-entered. */
67
85
  export function removeAgentNotifier(config) {
68
86
  removeNotifierFile(agentDirFor(config));
69
87
  }
70
88
  /** The notifier settings that apply to an agent: its code httpNotifier,
71
- * else its notifier.yml (once its secret is set), else the
89
+ * else its messaging.yml (once its secret is set), else the
72
90
  * deployment-wide default webhook. */
73
91
  function activeNotifierTarget(config, defaultWebhook) {
74
92
  if (config.httpNotifier)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",