loopengine 0.1.84 → 0.1.86

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
@@ -185,10 +185,11 @@ only their own message to tell whether it's a decision, and anything
185
185
  unclear is treated as a question. An approver can also change a request
186
186
  first ("make it $40"): the change is checked against the tool's schema
187
187
  and the permission rules and shown with **Approve as changed / Keep
188
- original / Deny**, and nothing runs until they approve it. For
189
- **Confirm-required tools**, a typed yes only brings up a Confirm button
190
- showing exactly what will run. Once approvers are listed, only their
191
- button clicks count too. The same tab lists every decision
188
+ original / Deny**, and nothing runs until they approve it. A typed reply
189
+ never runs anything by itself: a "yes" shows exactly what will run with
190
+ a **Confirm** button, and only that click runs it. Arguments are shown
191
+ as labelled fields, with long text such as an email body quoted, not as
192
+ JSON. Once approvers are listed, only their button clicks count too. The same tab lists every decision
192
193
  still waiting with Approve/Deny buttons (its count shows on the tab), and
193
194
  warns you when an agent has `ask` rules but nobody would be told.
194
195
 
@@ -25,6 +25,7 @@
25
25
  // call shape).
26
26
  import { randomUUID } from 'node:crypto';
27
27
  import { decidePath } from '../approval-links.js';
28
+ import { slackArgs, slackEscape, toSlackMrkdwn } from '../messaging/connectors/slack/format.js';
28
29
  import { publicBaseUrl } from './webhook.js';
29
30
  export class SlackNotifier {
30
31
  botToken;
@@ -72,9 +73,12 @@ export class SlackNotifier {
72
73
  type: 'section',
73
74
  text: {
74
75
  type: 'mrkdwn',
75
- text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}${this.decideLine(pendingId, 'approval')}`,
76
+ // Arguments as labelled lines, long text quoted (not JSON);
77
+ // a Slack section holds 3,000 characters, so they're kept under.
78
+ text: fitSection(`*Approval requested:* \`${tool}\``, slackArgs(args), `*Why:* ${toSlackMrkdwn(slackEscape(reason))}${this.decideLine(pendingId, 'approval')}`),
76
79
  },
77
80
  },
81
+ { type: 'context', elements: [{ type: 'mrkdwn', text: `${scope.agent} · ${scope.tenant}/${scope.environment}` }] },
78
82
  ...(this.buttons
79
83
  ? [{
80
84
  type: 'actions',
@@ -99,7 +103,7 @@ export class SlackNotifier {
99
103
  action_id: `answer_option_${i}`,
100
104
  }));
101
105
  this.postMessage(`Question from ${agent}: ${question}`, [
102
- { type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}${this.decideLine(pendingId, 'question')}` } },
106
+ { type: 'section', text: { type: 'mrkdwn', text: fitSection(`*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*`, toSlackMrkdwn(slackEscape(question)), this.decideLine(pendingId, 'question').trim()) } },
103
107
  ...(this.buttons
104
108
  ? [{ type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] }]
105
109
  : []),
@@ -134,6 +138,13 @@ export class SlackNotifier {
134
138
  });
135
139
  }
136
140
  }
141
+ const SECTION_MAX = 3000;
142
+ /** A head line, a body cut to fit, and a tail line, in one Slack section. */
143
+ function fitSection(head, body, tail) {
144
+ const room = SECTION_MAX - head.length - tail.length - 60;
145
+ const fitted = body.length > room ? body.slice(0, Math.max(0, room)).trimEnd() + '\n… (cut short here; the decision link has it all)' : body;
146
+ return [head, fitted, tail].filter(Boolean).join('\n');
147
+ }
137
148
  /** One plain chat.postMessage, awaited, reporting whether Slack accepted
138
149
  * it — backs the console's "Send test" for a Slack notifier, where
139
150
  * SlackNotifier.postMessage's fire-and-forget contract would hide the
@@ -42,6 +42,9 @@ export interface MessagingConnector {
42
42
  name: string;
43
43
  /** How to refer to a person in a message (Slack: <@U123>). */
44
44
  mention(user: string): string;
45
+ /** Makes text from anyone else (a tool's arguments, an agent's reason)
46
+ * safe to put in a message, so it can't become a mention or a link. */
47
+ escape(text: string): string;
45
48
  /** The thread's messages, oldest first, its first message included. */
46
49
  fetchThread(conversation: string, threadTs: string): Promise<ThreadMessage[]>;
47
50
  postInThread(conversation: string, threadTs: string, text: string): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { toSlackMrkdwn } from './format.js';
1
+ import { slackEscape, toSlackMrkdwn } from './format.js';
2
2
  /** Reads an Events API body. Only people's new messages come through;
3
3
  * edits, deletions, joins and every bot's messages (this agent's own
4
4
  * included) are `ignore`. */
@@ -40,6 +40,7 @@ export function createSlackConnector(botToken) {
40
40
  app: 'slack',
41
41
  name: 'Slack',
42
42
  mention: (user) => `<@${user}>`,
43
+ escape: slackEscape,
43
44
  // Needs channels:history or groups:history, depending on the channel.
44
45
  async fetchThread(conversation, threadTs) {
45
46
  const params = new URLSearchParams({ channel: conversation, ts: threadTs, limit: '100' });
@@ -1 +1,7 @@
1
1
  export declare function toSlackMrkdwn(markdown: string): string;
2
+ /** Slack's own escaping, for text that isn't ours (an argument, a
3
+ * reason): with &, < and > escaped, a value can't become a mention, a
4
+ * channel-wide ping (<!channel>) or a disguised link. */
5
+ export declare function slackEscape(text: string): string;
6
+ /** A call's arguments as Slack mrkdwn (core/messaging/format-args.ts). */
7
+ export declare function slackArgs(args: Record<string, unknown>, schema?: Record<string, unknown>): string;
@@ -1,13 +1,22 @@
1
1
  // Models write Markdown even when asked for Slack's own format, and Slack
2
2
  // shows Markdown's **bold**, [links](url) and # headings as literal
3
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]*`)/;
4
+ // reply is posted; code (```blocks``` and `spans`) is left as written,
5
+ // and so is text slackEscape marked as someone else's.
6
+ import { formatArgs } from '../../format-args.js';
7
+ // slackEscape wraps text that isn't ours in these, so the conversion
8
+ // below never turns it into formatting or a link; they're removed after.
9
+ const OPEN = '\u0001';
10
+ const CLOSE = '\u0002';
11
+ const KEEP = /(```[\s\S]*?```|`[^`\n]*`|\u0001[\s\S]*?\u0002)/;
6
12
  export function toSlackMrkdwn(markdown) {
7
13
  return markdown
8
- .split(CODE)
14
+ // ~~old value~~ around protected text: the markers sit outside it.
15
+ .replace(/~~(\u0001[^\u0002]*\u0002)~~/g, '~$1~')
16
+ .split(KEEP)
9
17
  .map((part, i) => (i % 2 === 1 ? part : convertProse(part)))
10
- .join('');
18
+ .join('')
19
+ .replace(/[\u0001\u0002]/g, '');
11
20
  }
12
21
  function convertProse(text) {
13
22
  const bold = [];
@@ -32,3 +41,13 @@ function convertProse(text) {
32
41
  out = out.replace(/\u0000(\d+)\u0000/g, (_m, n) => `*${bold[Number(n)]}*`);
33
42
  return out;
34
43
  }
44
+ /** Slack's own escaping, for text that isn't ours (an argument, a
45
+ * reason): with &, < and > escaped, a value can't become a mention, a
46
+ * channel-wide ping (<!channel>) or a disguised link. */
47
+ export function slackEscape(text) {
48
+ return OPEN + text.replace(/[\u0001\u0002]/g, '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + CLOSE;
49
+ }
50
+ /** A call's arguments as Slack mrkdwn (core/messaging/format-args.ts). */
51
+ export function slackArgs(args, schema) {
52
+ return toSlackMrkdwn(formatArgs(args, slackEscape, schema));
53
+ }
@@ -1,4 +1,4 @@
1
1
  export { createSlackConnector, parseSlackEvent, type SlackEvent } from './events.js';
2
- export { toSlackMrkdwn } from './format.js';
2
+ export { slackArgs, slackEscape, toSlackMrkdwn } from './format.js';
3
3
  export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, verifySlackRequest, type SlackInteraction, } from './interactions.js';
4
4
  export { listSlackChannelMembers, listSlackChannels, slackAppManifest, type SlackChannel, type SlackMember } from './setup.js';
@@ -4,6 +4,6 @@
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
+ export { slackArgs, slackEscape, toSlackMrkdwn } from './format.js';
8
8
  export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, verifySlackRequest, } from './interactions.js';
9
9
  export { listSlackChannelMembers, listSlackChannels, slackAppManifest } from './setup.js';
@@ -25,8 +25,6 @@ export interface MessagingDeps {
25
25
  /** Records a decision on a pending request, as the console's own
26
26
  * Approve/Deny/Answer would. */
27
27
  decide(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome>;
28
- /** Tools whose approval needs a Confirm click (Messaging tab). */
29
- confirmTools(): string[];
30
28
  /** The tool's argument schema, for proposing a change. */
31
29
  toolSchema(tool: string): Promise<Record<string, unknown> | undefined>;
32
30
  /** Why changed arguments can't be used (they don't fit the tool's
@@ -1,5 +1,6 @@
1
1
  import { KeyedMutex } from '../session-store.js';
2
- import { decideReply, describeChange, proposeEdit } from './decide.js';
2
+ import { decideReply, proposeEdit } from './decide.js';
3
+ import { formatArgs, formatChange } from './format-args.js';
3
4
  import { findThread, findThreadByPending, recordThread } from './threads.js';
4
5
  // An app may redeliver a message it thinks went unanswered (and Slack
5
6
  // sends a reply that @mentions the bot as both `message` and
@@ -84,7 +85,7 @@ export async function handleInboundMessage(message, deps) {
84
85
  return;
85
86
  }
86
87
  recordThread(deps.agentDir, { ...link, proposedArgs: proposed });
87
- await connector.postChoice(message.conversation, threadTs, `Here's the change ${who} asked for:\n${describeChange(item.args, proposed).map((l) => `• ${l}`).join('\n')}\nNothing else changes.`, [
88
+ await connector.postChoice(message.conversation, threadTs, `Here's the change ${who} asked for:\n${formatChange(item.args, proposed, connector.escape, await deps.toolSchema(item.tool))}\nNothing else changes.`, [
88
89
  { action: 'approve_edited', label: 'Approve as changed', style: 'primary', pendingId: link.pendingId },
89
90
  { action: 'keep_original', label: 'Keep original', pendingId: link.pendingId },
90
91
  { action: 'deny', label: 'Deny', style: 'danger', pendingId: link.pendingId },
@@ -93,21 +94,18 @@ export async function handleInboundMessage(message, deps) {
93
94
  }
94
95
  if (verdict.decision === 'approve') {
95
96
  const args = link.proposedArgs ?? item.args;
96
- if (deps.confirmTools().includes(item.tool)) {
97
- await connector.postChoice(message.conversation, threadTs, `\`${item.tool}\` needs a Confirm click. It will run with \`${JSON.stringify(args)}\`.`, [
98
- { action: 'confirm', label: 'Confirm', style: 'primary', pendingId: link.pendingId },
99
- { action: 'cancel_confirm', label: 'Cancel', pendingId: link.pendingId },
100
- ]);
101
- return;
102
- }
103
- await runApproved(deps, link, item.tool, args, who);
97
+ await connector.postChoice(message.conversation, threadTs, `Confirm to run \`${item.tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(item.tool))}`, [
98
+ { action: 'confirm', label: 'Confirm', style: 'primary', pendingId: link.pendingId },
99
+ { action: 'cancel_confirm', label: 'Cancel', pendingId: link.pendingId },
100
+ ]);
104
101
  return;
105
102
  }
106
103
  const outcome = await deps.decide(link.pendingId, verdict.decision === 'answer' ? { kind: 'answer', answer: verdict.answer } : { kind: 'deny' });
107
- await say(outcomeText(verdict.decision, who, outcome, verdict.decision === 'answer' ? verdict.answer : undefined));
104
+ const answer = verdict.decision === 'answer' ? connector.escape(verdict.answer) : undefined;
105
+ await say(outcomeText(verdict.decision, who, outcome, answer));
108
106
  if (!outcome.error && !outcome.alreadyResolved) {
109
107
  const what = link.kind === 'approval' ? `\`${item.tool}\`` : 'The question';
110
- await replaceRequest(connector, link, `${what} was ${verdict.decision === 'deny' ? 'denied' : `answered ("${verdict.answer}")`} by ${who} in the thread.`);
108
+ await replaceRequest(connector, link, `${what} was ${verdict.decision === 'deny' ? 'denied' : `answered ("${answer}")`} by ${who} in the thread.`);
111
109
  }
112
110
  return;
113
111
  }
@@ -141,7 +139,7 @@ export async function handleInboundMessage(message, deps) {
141
139
  async function runApproved(deps, link, tool, args, who) {
142
140
  const { connector } = deps;
143
141
  const edited = Boolean(link.proposedArgs);
144
- await connector.postInThread(link.conversation, link.ts, `Approved${edited ? ' as changed' : ''} by ${who}. Running \`${tool}\` with \`${JSON.stringify(args)}\`.`);
142
+ await connector.postInThread(link.conversation, link.ts, `Approved${edited ? ' as changed' : ''} by ${who}. Running \`${tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(tool))}`);
145
143
  const outcome = await deps.decide(link.pendingId, { kind: 'approve', ...(edited ? { editedArgs: args } : {}) });
146
144
  await connector.postInThread(link.conversation, link.ts, outcomeText('approve', who, outcome));
147
145
  if (!outcome.error && !outcome.alreadyResolved)
@@ -191,7 +189,7 @@ export function outcomeText(decision, who, outcome, answer) {
191
189
  return `${what}. The agent is still waiting on another decision from the same step.`;
192
190
  if (outcome.morePending)
193
191
  return `${what}. The agent needs another decision; a new request is on its way.`;
194
- return `${what}.${outcome.text ? `\n>${outcome.text.replace(/\n/g, '\n>')}` : ''}`;
192
+ return `${what}.${outcome.text ? `\n> ${outcome.text.replace(/\n/g, '\n> ')}` : ''}`;
195
193
  }
196
194
  /** The thread as chat messages: the agent's own replies as `assistant`,
197
195
  * people's as `user` (with who said it), the request itself left out
@@ -0,0 +1,10 @@
1
+ type Escape = (text: string) => string;
2
+ /** "customer_id" / "customerId" -> "Customer id". */
3
+ export declare function labelFor(key: string, schema?: Record<string, unknown>): string;
4
+ /** All of a call's arguments, one per line (long text quoted below its
5
+ * label). */
6
+ export declare function formatArgs(args: Record<string, unknown>, escape: Escape, schema?: Record<string, unknown>): string;
7
+ /** Only the arguments that differ, old struck through and new beside it;
8
+ * changed long text is shown in full as it will run. */
9
+ export declare function formatChange(before: Record<string, unknown>, after: Record<string, unknown>, escape: Escape, schema?: Record<string, unknown>): string;
10
+ export {};
@@ -0,0 +1,107 @@
1
+ // A tool call's arguments as something a person can read in a chat app,
2
+ // instead of JSON: one labelled line per argument, long or multi-line
3
+ // text (an email body) as a quoted block with its line breaks, lists as
4
+ // bullets. Labels come from the tool's schema (a property's `title`),
5
+ // else from the argument's name ("customer_id" -> "Customer id").
6
+ //
7
+ // The result is Markdown, which each connector turns into its own format
8
+ // (Slack: ./connectors/slack/format.ts). Every value goes through the
9
+ // connector's `escape`, so text the agent wrote can't become a mention,
10
+ // a link or a ping in the app.
11
+ const INLINE_MAX = 80;
12
+ const LONG_MAX = 1500;
13
+ function propertiesOf(schema) {
14
+ const props = schema?.properties;
15
+ return props && typeof props === 'object' ? props : {};
16
+ }
17
+ /** A label comes from the tool's own schema, not from the agent, but is
18
+ * still kept plain: no markup or link characters. */
19
+ function plain(label) {
20
+ return label.replace(/[*_~`<>&|[\]]/g, '').trim() || 'Value';
21
+ }
22
+ /** "customer_id" / "customerId" -> "Customer id". */
23
+ export function labelFor(key, schema) {
24
+ const title = propertiesOf(schema)[key]?.title;
25
+ if (typeof title === 'string' && title.trim())
26
+ return title.trim();
27
+ const words = key
28
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
29
+ .replace(/[_-]+/g, ' ')
30
+ .trim()
31
+ .toLowerCase();
32
+ return words ? words[0].toUpperCase() + words.slice(1) : key;
33
+ }
34
+ /** Keys in the schema's order, then any others. */
35
+ function orderedKeys(args, schema) {
36
+ const fromSchema = Object.keys(propertiesOf(schema)).filter((k) => k in args);
37
+ return [...fromSchema, ...Object.keys(args).filter((k) => !fromSchema.includes(k))];
38
+ }
39
+ function isLongText(value) {
40
+ return typeof value === 'string' && (value.length > INLINE_MAX || value.includes('\n'));
41
+ }
42
+ function quote(text, escape) {
43
+ const cut = text.length > LONG_MAX ? text.slice(0, LONG_MAX).trimEnd() + '… (cut short here; the decision link has it all)' : text;
44
+ return cut
45
+ .split('\n')
46
+ .map((line) => `> ${escape(line)}`)
47
+ .join('\n');
48
+ }
49
+ function inline(value, escape) {
50
+ if (typeof value === 'string')
51
+ return escape(value);
52
+ if (value === null || typeof value !== 'object')
53
+ return String(value);
54
+ return '`' + escape(JSON.stringify(value)) + '`';
55
+ }
56
+ /** One argument: `**Label:** value`, or the label over a quoted block,
57
+ * bullets or nested fields. */
58
+ function field(label, value, escape, indent = '') {
59
+ const head = `${indent}**${plain(label)}:**`;
60
+ if (isLongText(value))
61
+ return `${head}\n${quote(value, escape)}`;
62
+ if (Array.isArray(value)) {
63
+ if (!value.length)
64
+ return `${head} (none)`;
65
+ const simple = value.every((v) => v === null || typeof v !== 'object');
66
+ const joined = value.map((v) => inline(v, escape)).join(', ');
67
+ if (simple && joined.length <= INLINE_MAX)
68
+ return `${head} ${joined}`;
69
+ return `${head}\n${value.map((v) => `${indent}- ${isLongText(v) ? escape(v.slice(0, 300)) : inline(v, escape)}`).join('\n')}`;
70
+ }
71
+ if (value && typeof value === 'object') {
72
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined);
73
+ if (!entries.length)
74
+ return `${head} (empty)`;
75
+ if (indent)
76
+ return `${head} ${inline(value, escape)}`;
77
+ return `${head}\n${entries.map(([k, v]) => field(labelFor(k), v, escape, ' ')).join('\n')}`;
78
+ }
79
+ return `${head} ${inline(value, escape)}`;
80
+ }
81
+ /** All of a call's arguments, one per line (long text quoted below its
82
+ * label). */
83
+ export function formatArgs(args, escape, schema) {
84
+ const keys = orderedKeys(args, schema).filter((k) => args[k] !== undefined && args[k] !== null);
85
+ if (!keys.length)
86
+ return '(no arguments)';
87
+ return keys.map((k) => field(labelFor(k, schema), args[k], escape)).join('\n');
88
+ }
89
+ /** Only the arguments that differ, old struck through and new beside it;
90
+ * changed long text is shown in full as it will run. */
91
+ export function formatChange(before, after, escape, schema) {
92
+ const keys = [...new Set([...orderedKeys(after, schema), ...Object.keys(before)])].filter((k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]));
93
+ return keys
94
+ .map((k) => {
95
+ const label = `**${plain(labelFor(k, schema))}:**`;
96
+ const was = before[k];
97
+ const now = after[k];
98
+ if (now === undefined || now === null)
99
+ return `${label} ~~${inline(was, escape)}~~ (removed)`;
100
+ if (isLongText(now) || isLongText(was))
101
+ return `${label} (changed, now:)\n${quote(typeof now === 'string' ? now : JSON.stringify(now), escape)}`;
102
+ if (was === undefined || was === null)
103
+ return `${label} ${inline(now, escape)} (added)`;
104
+ return `${label} ~~${inline(was, escape)}~~ → ${inline(now, escape)}`;
105
+ })
106
+ .join('\n');
107
+ }
@@ -20,8 +20,6 @@ export interface NotifierFile {
20
20
  /** slack: the people who can decide a request by replying in its
21
21
  * thread, and whose button clicks count once any are listed. */
22
22
  approvers?: MessagingApprover[];
23
- /** slack: tools whose approval by reply needs a Confirm click. */
24
- confirmTools?: string[];
25
23
  }
26
24
  export interface MessagingApprover {
27
25
  /** The app's id for the person (Slack: U0123ABC). */
@@ -81,13 +81,6 @@ export function validateNotifierFile(input) {
81
81
  if (approvers.length)
82
82
  file.approvers = approvers;
83
83
  }
84
- if (raw.confirmTools !== undefined && channel === 'slack') {
85
- if (!Array.isArray(raw.confirmTools) || raw.confirmTools.some((t) => typeof t !== 'string' || !t.trim()))
86
- throw new NotifierFileError('confirmTools must be a list of tool names');
87
- const tools = [...new Set(raw.confirmTools.map((t) => t.trim()))];
88
- if (tools.length)
89
- file.confirmTools = tools;
90
- }
91
84
  const publicUrl = str('publicUrl');
92
85
  if (publicUrl) {
93
86
  if (!/^https?:\/\/[^/]+/.test(publicUrl))
package/dist/index.d.ts CHANGED
@@ -13,14 +13,15 @@ export { describeNotifier } from '#core/http-notifier.js';
13
13
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
14
14
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
15
  export { firstSighting, handleInboundMessage, handleMessagingAction, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
16
+ export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
16
17
  export { decideReply, proposeEdit, describeChange, type ReplyDecision, type DecisionRequest } from '#core/messaging/decide.js';
17
18
  export { recordThread, findThread, findThreadByPending, type ThreadLink } from '#core/messaging/threads.js';
18
19
  export type { InboundMessage, MessagingConnector, ThreadMessage, MessagingChoice, MessagingAction } from '#core/messaging/connector.js';
19
- export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn, type SlackEvent, type SlackInteraction, type SlackChannel } from '#core/messaging/connectors/slack/index.js';
20
+ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn, slackArgs, slackEscape, type SlackEvent, type SlackInteraction, type SlackChannel } from '#core/messaging/connectors/slack/index.js';
20
21
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
21
22
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
22
23
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
23
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, agentConfirmTools, setAgentConfirmTools, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
24
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
24
25
  export type * from './core/loop-events.js';
25
26
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
26
27
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
package/dist/index.js CHANGED
@@ -15,13 +15,14 @@ export { describeNotifier } from '#core/http-notifier.js';
15
15
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
16
16
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
17
17
  export { firstSighting, handleInboundMessage, handleMessagingAction } from '#core/messaging/engine.js';
18
+ export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
18
19
  export { decideReply, proposeEdit, describeChange } from '#core/messaging/decide.js';
19
20
  export { recordThread, findThread, findThreadByPending } from '#core/messaging/threads.js';
20
- export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn } from '#core/messaging/connectors/slack/index.js';
21
+ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, replyPrivatelyInSlack, slackOutcomeText, listSlackChannels, listSlackChannelMembers, slackAppManifest, toSlackMrkdwn, slackArgs, slackEscape } from '#core/messaging/connectors/slack/index.js';
21
22
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
22
23
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
23
24
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
24
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, agentConfirmTools, setAgentConfirmTools, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
25
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, slackClickAllowed, agentApprovers, setAgentApprovers, listAgentSlackMembers, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
25
26
  // The framework-agnostic browser client for adapters/http.ts's own wire
26
27
  // protocol (see client.ts's own header comment) — this, not a deep
27
28
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -1194,7 +1194,7 @@ ${consoleShellScript}
1194
1194
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
1195
1195
  }).join('');
1196
1196
  return '<section id="approversSection"><h3>Approvers</h3>' +
1197
- '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A change is shown to approve before anything runs. Once anyone is listed, only approvers&#39; button clicks count too. Anyone in the channel can still ask about a request.</p>' +
1197
+ '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. Nothing runs on a typed reply alone: a yes shows exactly what will run with a Confirm button, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count too. Anyone in the channel can still ask about a request.</p>' +
1198
1198
  (approvers.length
1199
1199
  ? '<div class="le-table-wrap" style="max-width:560px"><table><thead><tr><th>Name</th><th>Slack ID</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
1200
1200
  : '<p class="hint">No approvers yet. Until you add someone, anyone in the channel can use the buttons, and deciding by reply is off.</p>') +
@@ -1205,39 +1205,6 @@ ${consoleShellScript}
1205
1205
  '<div id="approversResult" class="hint"></div></section>';
1206
1206
  }
1207
1207
 
1208
- // Slack only: tools an approver can't approve with a typed yes; the
1209
- // agent asks for a Confirm click showing exactly what will run.
1210
- function renderConfirmToolsHtml(n) {
1211
- if (n.source === 'code' || !n.file || n.file.channel !== 'slack') return '';
1212
- var chosen = n.file.confirmTools || [];
1213
- var tools = ((currentCfg && currentCfg.tools) || []).map(function (t) { return t.name; });
1214
- chosen.forEach(function (t) { if (tools.indexOf(t) === -1) tools.push(t); });
1215
- var boxes = tools.map(function (t) {
1216
- return '<label class="tool-picker-item" style="justify-content:flex-start"><input type="checkbox" value="' + escapeHtml(t) + '"' + (chosen.indexOf(t) !== -1 ? ' checked' : '') + (n.canSave === false ? ' disabled' : '') + '> <code>' + escapeHtml(t) + '</code></label>';
1217
- }).join('');
1218
- return '<section id="confirmToolsSection"><h3>Confirm-required tools</h3>' +
1219
- '<p class="muted" style="margin:0 0 10px">For tools that move money or delete data: when an approver replies &ldquo;yes&rdquo;, the agent shows exactly what will run with a Confirm button, and only that click runs it. A click on the request&#39;s own Approve button still runs it straight away.</p>' +
1220
- (tools.length ? '<div class="tool-picker" style="max-width:560px">' + boxes + '</div>' : '<p class="hint">This agent has no tools.</p>') +
1221
- (n.canSave === false || !tools.length ? '' : '<p style="margin:8px 0 0"><button type="button" id="confirmToolsSave">Save</button></p>') +
1222
- '<div id="confirmToolsResult" class="hint"></div></section>';
1223
- }
1224
-
1225
- function wireConfirmToolsHandlers(name) {
1226
- var section = detail.querySelector('#confirmToolsSection');
1227
- var btn = section && section.querySelector('#confirmToolsSave');
1228
- if (!btn) return;
1229
- var result = section.querySelector('#confirmToolsResult');
1230
- btn.addEventListener('click', function () {
1231
- var tools = Array.prototype.map.call(section.querySelectorAll('input:checked'), function (el) { return el.value; });
1232
- result.textContent = 'Saving' + String.fromCharCode(8230);
1233
- fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/confirm-tools', {
1234
- method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tools: tools }),
1235
- }).then(function (r) {
1236
- result.textContent = r.ok ? 'Saved.' : (r.body.error || 'Could not save');
1237
- });
1238
- });
1239
- }
1240
-
1241
1208
  function wireApproversHandlers(name, n) {
1242
1209
  var section = detail.querySelector('#approversSection');
1243
1210
  if (!section) return;
@@ -1294,7 +1261,6 @@ ${consoleShellScript}
1294
1261
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
1295
1262
  (n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
1296
1263
  renderApproversHtml(n) +
1297
- renderConfirmToolsHtml(n) +
1298
1264
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
1299
1265
  }
1300
1266
 
@@ -1456,7 +1422,6 @@ ${consoleShellScript}
1456
1422
  content.innerHTML = renderApprovalsHtml(results[0].body);
1457
1423
  wireApprovalsHandlers(name);
1458
1424
  wireApproversHandlers(name, results[0].body);
1459
- wireConfirmToolsHandlers(name);
1460
1425
  var pendingEl = detail.querySelector('#pendingContent');
1461
1426
  var pending = (results[1].ok && results[1].body.pending) || [];
1462
1427
  if (pendingEl) {
@@ -63,11 +63,6 @@ export declare function slackClickAllowed(config: AgentConfig, user: string | un
63
63
  /** Replaces the approver list on the agent's saved Slack connection.
64
64
  * Throws NotifierFileError when there's no Slack connection to add them to. */
65
65
  export declare function setAgentApprovers(config: AgentConfig, approvers: unknown): MessagingApprover[];
66
- /** Tools whose approval by reply needs a Confirm click. */
67
- export declare function agentConfirmTools(config: AgentConfig): string[];
68
- /** Replaces the Confirm-required tools on the agent's saved Slack
69
- * connection. Throws NotifierFileError without one. */
70
- export declare function setAgentConfirmTools(config: AgentConfig, tools: unknown): string[];
71
66
  /** The people in the agent's Slack channel, for picking approvers. */
72
67
  export declare function listAgentSlackMembers(config: AgentConfig): Promise<SlackMember[]>;
73
68
  /** Channels for the console's picker, with the token being typed in or,
@@ -42,8 +42,8 @@ export function describeAgentNotifier(config, defaultWebhook) {
42
42
  * form includes one, the channel's secret into the agent's own env.
43
43
  * Throws NotifierFileError for an invalid form. */
44
44
  export async function saveAgentNotifier(config, body) {
45
- // The form doesn't carry the approvers or the Confirm-required tools
46
- // (they have their own sections), so saving it keeps them.
45
+ // The form doesn't carry the approvers (they have their own section),
46
+ // so saving it keeps them.
47
47
  let existing;
48
48
  try {
49
49
  existing = readNotifierFile(agentDirFor(config));
@@ -52,8 +52,6 @@ export async function saveAgentNotifier(config, body) {
52
52
  if (existing && body.channel === existing.channel) {
53
53
  if (body.approvers === undefined && existing.approvers)
54
54
  body = { ...body, approvers: existing.approvers };
55
- if (body.confirmTools === undefined && existing.confirmTools)
56
- body = { ...body, confirmTools: existing.confirmTools };
57
55
  }
58
56
  const file = writeNotifierFile(agentDirFor(config), body);
59
57
  if (typeof body.secret === 'string' && body.secret.trim()) {
@@ -115,26 +113,6 @@ export function setAgentApprovers(config, approvers) {
115
113
  throw new NotifierFileError('Save a Slack connection first');
116
114
  return writeNotifierFile(dir, { ...file, approvers: Array.isArray(approvers) ? approvers : [] }).approvers ?? [];
117
115
  }
118
- /** Tools whose approval by reply needs a Confirm click. */
119
- export function agentConfirmTools(config) {
120
- if (config.httpNotifier)
121
- return [];
122
- try {
123
- return readNotifierFile(agentDirFor(config))?.confirmTools ?? [];
124
- }
125
- catch {
126
- return [];
127
- }
128
- }
129
- /** Replaces the Confirm-required tools on the agent's saved Slack
130
- * connection. Throws NotifierFileError without one. */
131
- export function setAgentConfirmTools(config, tools) {
132
- const dir = agentDirFor(config);
133
- const file = readNotifierFile(dir);
134
- if (!file || file.channel !== 'slack')
135
- throw new NotifierFileError('Save a Slack connection first');
136
- return writeNotifierFile(dir, { ...file, confirmTools: Array.isArray(tools) ? tools : [] }).confirmTools ?? [];
137
- }
138
116
  /** The people in the agent's Slack channel, for picking approvers. */
139
117
  export async function listAgentSlackMembers(config) {
140
118
  const { botToken } = slackCredentialsFor(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",