loopengine 0.1.66 → 0.1.68

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
@@ -171,6 +171,34 @@ export const config: AgentConfig = {
171
171
  }
172
172
  ```
173
173
 
174
+ Or skip the code: in the console, the Permissions tab's **Where approval
175
+ requests and questions are sent** section sets a
176
+ webhook, Slack or Lark target for one agent (saved to
177
+ `agents/<name>/notifier.yml`, with its secret in the agent's own `.env`),
178
+ sends a test, shows recent deliveries, and lists every decision still
179
+ waiting with Approve/Deny buttons. The tab warns you when an agent has
180
+ `ask` rules but nobody would be told.
181
+
182
+ Every notification links to a **decision page**: a signed, single-use
183
+ link (`/decide/<token>`, valid 7 days) where whoever got the message can
184
+ approve, deny, edit the arguments or answer a question, with no login and
185
+ no app of your own. So the quickest setup is Slack: paste a bot token,
186
+ pick a channel, save; each approval arrives with an "Open to approve or
187
+ deny" link.
188
+
189
+ For a third-party app calling an agent over plain HTTP:
190
+
191
+ 1. `POST /agents/<name>/messages` → when a call needs approval, the reply
192
+ has `stopReason: "pending_approval"` and a `pending` list, each item
193
+ with its `pendingId` and `resolvePath`.
194
+ 2. The webhook carries the same `pendingId`, `resolvePath` (and a full
195
+ `resolveUrl` when `LOOPENGINE_PUBLIC_URL` is set), signed with
196
+ `X-Actauth-Signature`. Failed deliveries are retried.
197
+ 3. `POST <resolvePath>` with `{"decision":"approve"}` (or `"deny"`) runs
198
+ the tool, resumes the turn, and returns the agent's reply.
199
+ `GET /agents/<name>/pending` lists what's waiting, for apps that poll
200
+ instead of receiving webhooks.
201
+
174
202
  Reference implementations for the receiving side (verifying a Slack
175
203
  click, a webhook HMAC signature, a magic-link token) ship under
176
204
  `examples/notifier-handler/` for the four channels that need one. See
@@ -185,6 +185,7 @@ export type HttpNotifierConfig = {
185
185
  config: {
186
186
  webhookUrl: string;
187
187
  webhookSecret: string;
188
+ publicUrl?: string;
188
189
  };
189
190
  events: HttpNotifierEvent[];
190
191
  } | {
@@ -192,6 +193,8 @@ export type HttpNotifierConfig = {
192
193
  config: {
193
194
  botToken: string;
194
195
  channelId: string;
196
+ publicUrl?: string;
197
+ buttons?: boolean;
195
198
  };
196
199
  events: HttpNotifierEvent[];
197
200
  } | {
@@ -0,0 +1,16 @@
1
+ export type DecisionKind = 'approval' | 'question';
2
+ /** How long a link works: 7 days, or LOOPENGINE_LINK_TTL_HOURS. */
3
+ export declare function decisionLinkTtlMs(): number;
4
+ export declare function createDecisionToken(pendingId: string, kind: DecisionKind, nowMs?: number): string;
5
+ /** The pendingId and kind a token names, or null for a malformed,
6
+ * tampered or expired one. */
7
+ export declare function verifyDecisionToken(token: string, nowMs?: number): {
8
+ pendingId: string;
9
+ kind: DecisionKind;
10
+ expiresAt: number;
11
+ } | null;
12
+ export declare function decidePath(pendingId: string, kind: DecisionKind): string;
13
+ /** Builds full decision links against `baseUrl` — undefined when there's
14
+ * no base URL to build them against. */
15
+ export type DecideLinkMaker = (pendingId: string, kind: DecisionKind) => string | undefined;
16
+ export declare function decideLinkMaker(baseUrl: string | undefined): DecideLinkMaker;
@@ -0,0 +1,76 @@
1
+ // Decision links: a signed, expiring URL for one waiting approval or
2
+ // question (/decide/<token>) that opens a small page where whoever has
3
+ // the link can approve, deny or answer — no admin login, no app of your
4
+ // own, no Slack interactivity setup. Every notifier that knows where this
5
+ // server is reachable puts one in its message.
6
+ //
7
+ // The token only names the pendingId, its kind and an expiry, signed
8
+ // with HMAC-SHA256. It's single-use by construction: once the decision
9
+ // is made the pending item no longer exists, so the same link just shows
10
+ // "already decided."
11
+ //
12
+ // The signing secret is LOOPENGINE_LINK_SECRET when set (required for a
13
+ // deployment with more than one server, which must share it), otherwise
14
+ // one generated on first use and kept in .checkpoints/link-secret next to
15
+ // the paused turns it unlocks, so links keep working across restarts.
16
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ /** How long a link works: 7 days, or LOOPENGINE_LINK_TTL_HOURS. */
20
+ export function decisionLinkTtlMs() {
21
+ const hours = Number(process.env.LOOPENGINE_LINK_TTL_HOURS);
22
+ return (Number.isFinite(hours) && hours > 0 ? hours : 24 * 7) * 3_600_000;
23
+ }
24
+ let generatedSecret;
25
+ function linkSecret() {
26
+ if (process.env.LOOPENGINE_LINK_SECRET)
27
+ return process.env.LOOPENGINE_LINK_SECRET;
28
+ if (generatedSecret)
29
+ return generatedSecret;
30
+ const dir = join(process.cwd(), '.checkpoints');
31
+ const path = join(dir, 'link-secret');
32
+ if (existsSync(path)) {
33
+ generatedSecret = readFileSync(path, 'utf8').trim();
34
+ }
35
+ else {
36
+ generatedSecret = randomBytes(32).toString('hex');
37
+ mkdirSync(dir, { recursive: true });
38
+ writeFileSync(path, generatedSecret + '\n', { mode: 0o600 });
39
+ }
40
+ return generatedSecret;
41
+ }
42
+ function sign(body) {
43
+ return createHmac('sha256', linkSecret()).update(body).digest('base64url');
44
+ }
45
+ export function createDecisionToken(pendingId, kind, nowMs = Date.now()) {
46
+ const body = Buffer.from(JSON.stringify({ p: pendingId, k: kind, exp: nowMs + decisionLinkTtlMs() })).toString('base64url');
47
+ return `${body}.${sign(body)}`;
48
+ }
49
+ /** The pendingId and kind a token names, or null for a malformed,
50
+ * tampered or expired one. */
51
+ export function verifyDecisionToken(token, nowMs = Date.now()) {
52
+ const [body, signature] = token.split('.');
53
+ if (!body || !signature)
54
+ return null;
55
+ const expected = Buffer.from(sign(body));
56
+ const actual = Buffer.from(signature);
57
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
58
+ return null;
59
+ try {
60
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
61
+ if (typeof payload.p !== 'string' || (payload.k !== 'approval' && payload.k !== 'question') || typeof payload.exp !== 'number')
62
+ return null;
63
+ if (nowMs > payload.exp)
64
+ return null;
65
+ return { pendingId: payload.p, kind: payload.k, expiresAt: payload.exp };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ export function decidePath(pendingId, kind) {
72
+ return `/decide/${createDecisionToken(pendingId, kind)}`;
73
+ }
74
+ export function decideLinkMaker(baseUrl) {
75
+ return (pendingId, kind) => (baseUrl ? baseUrl.replace(/\/+$/, '') + decidePath(pendingId, kind) : undefined);
76
+ }
@@ -31,6 +31,9 @@ export interface TurnCheckpoint {
31
31
  * and is what DurableApprover.requestDurableApproval handed out. */
32
32
  outstanding: Record<string, OutstandingItem>;
33
33
  closed: boolean;
34
+ /** When the turn paused. Optional only because checkpoints written
35
+ * before this field existed don't have it. */
36
+ createdAt?: string;
34
37
  }
35
38
  export interface CreateCheckpointInput {
36
39
  sessionId: string;
@@ -58,6 +61,14 @@ export interface CheckpointStore {
58
61
  checkpoint: TurnCheckpoint | undefined;
59
62
  result: T;
60
63
  }>): Promise<T>;
64
+ /** Every checkpoint still waiting on at least one decision, oldest
65
+ * first — backs GET /agents/:name/pending. Reads without locking: a
66
+ * snapshot for display, not something to act on without withCheckpoint. */
67
+ listOpen(): Promise<TurnCheckpoint[]>;
68
+ /** The open checkpoint `pendingId` belongs to, read without locking —
69
+ * for showing a decision page, not for acting on it (use
70
+ * withCheckpoint for that). undefined once it's decided or unknown. */
71
+ get(pendingId: string): Promise<TurnCheckpoint | undefined>;
61
72
  close(): Promise<void>;
62
73
  }
63
74
  /** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
@@ -75,6 +86,8 @@ export declare class FileCheckpointStore implements CheckpointStore {
75
86
  checkpoint: TurnCheckpoint | undefined;
76
87
  result: T;
77
88
  }>): Promise<T>;
89
+ get(pendingId: string): Promise<TurnCheckpoint | undefined>;
90
+ listOpen(): Promise<TurnCheckpoint[]>;
78
91
  close(): Promise<void>;
79
92
  }
80
93
  /** Redis-backed store for multi-instance deployments. Unlike
@@ -98,6 +111,8 @@ export declare class RedisCheckpointStore implements CheckpointStore {
98
111
  checkpoint: TurnCheckpoint | undefined;
99
112
  result: T;
100
113
  }>): Promise<T>;
114
+ get(pendingId: string): Promise<TurnCheckpoint | undefined>;
115
+ listOpen(): Promise<TurnCheckpoint[]>;
101
116
  close(): Promise<void>;
102
117
  }
103
118
  /** Same REDIS_URL-gated pattern as session-store.ts's own
@@ -10,10 +10,13 @@
10
10
  // does (see HUMAN_IN_THE_LOOP.md's own "request + raw resolve only"
11
11
  // division of labor).
12
12
  import { randomUUID } from 'node:crypto';
13
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { Redis } from 'ioredis';
16
16
  import { KeyedMutex } from './session-store.js';
17
+ function sortOldestFirst(list) {
18
+ return list.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
19
+ }
17
20
  const fileMutex = new KeyedMutex();
18
21
  /** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
19
22
  * small pendingId -> checkpointId pointer file per outstanding item under
@@ -35,7 +38,7 @@ export class FileCheckpointStore {
35
38
  return path.join(this.indexDir, pendingId);
36
39
  }
37
40
  async create(input) {
38
- const checkpoint = { id: randomUUID(), closed: false, ...input };
41
+ const checkpoint = { id: randomUUID(), closed: false, createdAt: new Date().toISOString(), ...input };
39
42
  writeFileSync(this.checkpointPath(checkpoint.id), JSON.stringify(checkpoint));
40
43
  for (const pendingId of Object.keys(checkpoint.outstanding)) {
41
44
  writeFileSync(this.indexPath(pendingId), checkpoint.id);
@@ -69,6 +72,32 @@ export class FileCheckpointStore {
69
72
  return result;
70
73
  });
71
74
  }
75
+ async get(pendingId) {
76
+ const indexPath = this.indexPath(pendingId);
77
+ if (!existsSync(indexPath))
78
+ return undefined;
79
+ const checkpointPath = this.checkpointPath(readFileSync(indexPath, 'utf8'));
80
+ if (!existsSync(checkpointPath))
81
+ return undefined;
82
+ const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8'));
83
+ return !checkpoint.closed && checkpoint.outstanding[pendingId] ? checkpoint : undefined;
84
+ }
85
+ async listOpen() {
86
+ const open = [];
87
+ for (const name of readdirSync(this.checkpointsDir)) {
88
+ if (!name.endsWith('.json'))
89
+ continue;
90
+ try {
91
+ const checkpoint = JSON.parse(readFileSync(path.join(this.checkpointsDir, name), 'utf8'));
92
+ if (!checkpoint.closed && Object.keys(checkpoint.outstanding).length)
93
+ open.push(checkpoint);
94
+ }
95
+ catch {
96
+ // A checkpoint mid-write or removed between readdir and read.
97
+ }
98
+ }
99
+ return sortOldestFirst(open);
100
+ }
72
101
  async close() { }
73
102
  }
74
103
  /** Redis-backed store for multi-instance deployments. Unlike
@@ -117,7 +146,7 @@ export class RedisCheckpointStore {
117
146
  }
118
147
  }
119
148
  async create(input) {
120
- const checkpoint = { id: randomUUID(), closed: false, ...input };
149
+ const checkpoint = { id: randomUUID(), closed: false, createdAt: new Date().toISOString(), ...input };
121
150
  await this.redis.set(this.checkpointKey(checkpoint.id), JSON.stringify(checkpoint));
122
151
  for (const pendingId of Object.keys(checkpoint.outstanding)) {
123
152
  await this.redis.set(this.indexKey(pendingId), checkpoint.id);
@@ -145,6 +174,35 @@ export class RedisCheckpointStore {
145
174
  return result;
146
175
  });
147
176
  }
177
+ async get(pendingId) {
178
+ const checkpointId = await this.redis.get(this.indexKey(pendingId));
179
+ if (!checkpointId)
180
+ return undefined;
181
+ const raw = await this.redis.get(this.checkpointKey(checkpointId));
182
+ if (!raw)
183
+ return undefined;
184
+ const checkpoint = JSON.parse(raw);
185
+ return !checkpoint.closed && checkpoint.outstanding[pendingId] ? checkpoint : undefined;
186
+ }
187
+ async listOpen() {
188
+ const open = [];
189
+ let cursor = '0';
190
+ do {
191
+ // checkpoint:<id> only — checkpoint-lock:/checkpoint-pending-index:
192
+ // keys use a hyphen, so this pattern never matches them.
193
+ const [next, keys] = await this.redis.scan(cursor, 'MATCH', 'checkpoint:*', 'COUNT', 200);
194
+ cursor = next;
195
+ for (const key of keys) {
196
+ const raw = await this.redis.get(key);
197
+ if (!raw)
198
+ continue;
199
+ const checkpoint = JSON.parse(raw);
200
+ if (!checkpoint.closed && Object.keys(checkpoint.outstanding).length)
201
+ open.push(checkpoint);
202
+ }
203
+ } while (cursor !== '0');
204
+ return sortOldestFirst(open);
205
+ }
148
206
  async close() {
149
207
  await this.redis.quit();
150
208
  }
@@ -1,10 +1,25 @@
1
1
  import { type Approver } from 'actauth';
2
- import type { AgentConfig, QuestionHandler } from './agent-config.js';
2
+ import type { AgentConfig, HttpNotifierConfig, QuestionHandler } from './agent-config.js';
3
+ import { type NotifierFile } from './notifier-file.js';
3
4
  interface ResolvedHttpNotifier {
4
5
  approver?: Approver;
5
6
  questionHandler?: QuestionHandler;
6
7
  onRunStart?: NonNullable<AgentConfig['onRunStart']>;
7
8
  onRunFinish?: NonNullable<AgentConfig['onRunFinish']>;
8
9
  }
10
+ /** Where an agent's durable approvals/questions go, for the console:
11
+ * `code` (AgentConfig.httpNotifier), `file` (agents/<name>/notifier.yml),
12
+ * or `none`. A file whose secret isn't set yet is reported, not used. */
13
+ export declare function describeNotifier(config: AgentConfig): {
14
+ source: 'code' | 'file' | 'none';
15
+ channel?: HttpNotifierConfig['channel'];
16
+ events?: string[];
17
+ file?: NotifierFile;
18
+ missingSecret?: string;
19
+ error?: string;
20
+ };
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
23
+ * every call so a console edit applies to the very next turn. */
9
24
  export declare function resolveHttpNotifier(config: AgentConfig): ResolvedHttpNotifier;
10
25
  export {};
@@ -4,6 +4,8 @@ import { LarkNotifier } from './http-notify-triggers/lark.js';
4
4
  import { EmailNotifier } from './http-notify-triggers/email.js';
5
5
  import { DatabaseApprover } from './http-notify-triggers/database.js';
6
6
  import { RedisQueueApprover } from './http-notify-triggers/redis.js';
7
+ import { agentDirFor, createAgentEnv } from './agent-env.js';
8
+ import { notifierConfigFromFile, readNotifierFile } from './notifier-file.js';
7
9
  const EMPTY = {};
8
10
  // Keyed by the AgentConfig object itself, not its httpNotifier field
9
11
  // alone — resolveHttpNotifier is called on every runAgent()/resumeAgent()
@@ -29,22 +31,64 @@ function formatChatLifecycleMessage(event, context) {
29
31
  function formatEmailLifecycleSubject(event, context) {
30
32
  return event === 'run_start' ? `${context.agent} started` : `${context.agent} finished`;
31
33
  }
34
+ /** Where an agent's durable approvals/questions go, for the console:
35
+ * `code` (AgentConfig.httpNotifier), `file` (agents/<name>/notifier.yml),
36
+ * or `none`. A file whose secret isn't set yet is reported, not used. */
37
+ export function describeNotifier(config) {
38
+ if (config.httpNotifier)
39
+ return { source: 'code', channel: config.httpNotifier.channel, events: [...config.httpNotifier.events] };
40
+ const dir = agentDirFor(config);
41
+ let file;
42
+ try {
43
+ file = readNotifierFile(dir);
44
+ }
45
+ catch (err) {
46
+ return { source: 'none', error: `notifier.yml is invalid: ${err instanceof Error ? err.message : String(err)}` };
47
+ }
48
+ if (!file)
49
+ return { source: 'none' };
50
+ const { missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
51
+ return { source: missingSecret ? 'none' : 'file', channel: file.channel, events: file.events, file, missingSecret };
52
+ }
53
+ /** AgentConfig.httpNotifier when the agent sets one in code; otherwise
54
+ * its agents/<name>/notifier.yml (see core/notifier-file.ts), re-read on
55
+ * every call so a console edit applies to the very next turn. */
32
56
  export function resolveHttpNotifier(config) {
33
57
  const httpNotifier = config.httpNotifier;
34
58
  if (!httpNotifier)
35
- return EMPTY;
59
+ return resolveFromFile(config);
36
60
  const cached = cache.get(config);
37
61
  if (cached)
38
62
  return cached;
63
+ const resolved = buildResolved(httpNotifier);
64
+ cache.set(config, resolved);
65
+ return resolved;
66
+ }
67
+ function resolveFromFile(config) {
68
+ const dir = agentDirFor(config);
69
+ let file;
70
+ try {
71
+ file = readNotifierFile(dir);
72
+ }
73
+ catch (err) {
74
+ console.error(`[loopengine] ignoring agents/${config.name}/notifier.yml:`, err instanceof Error ? err.message : err);
75
+ return EMPTY;
76
+ }
77
+ if (!file)
78
+ return EMPTY;
79
+ const { config: httpNotifier } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
80
+ return httpNotifier ? buildResolved(httpNotifier) : EMPTY;
81
+ }
82
+ function buildResolved(httpNotifier) {
39
83
  const events = new Set(httpNotifier.events);
40
84
  let resolved;
41
85
  switch (httpNotifier.channel) {
42
86
  case 'webhook': {
43
- const { webhookUrl, webhookSecret } = httpNotifier.config;
87
+ const { webhookUrl, webhookSecret, publicUrl } = httpNotifier.config;
44
88
  // One shared instance for both concerns, same "it's channel-
45
89
  // specific, not concern-specific" reasoning every other channel's
46
90
  // own file already has — see WebhookNotifier's own doc comment.
47
- const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret });
91
+ const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret, publicUrl });
48
92
  resolved = {
49
93
  approver: events.has('approval') ? webhook : undefined,
50
94
  questionHandler: events.has('question') ? webhook : undefined,
@@ -54,13 +98,13 @@ export function resolveHttpNotifier(config) {
54
98
  break;
55
99
  }
56
100
  case 'slack': {
57
- const { botToken, channelId } = httpNotifier.config;
101
+ const { botToken, channelId, publicUrl, buttons } = httpNotifier.config;
58
102
  // One shared instance for every concern this httpNotifier actually
59
103
  // covers — same "it's Slack-specific, not approval/question/
60
104
  // lifecycle-specific" reasoning SlackNotifier's own doc
61
105
  // comment works out; cheap to construct (holds two strings, does no
62
106
  // I/O itself) so there's no reason to build one per event kind.
63
- const slack = new SlackNotifier({ botToken, channelId });
107
+ const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons });
64
108
  resolved = {
65
109
  approver: events.has('approval') ? slack : undefined,
66
110
  questionHandler: events.has('question') ? slack : undefined,
@@ -111,6 +155,5 @@ export function resolveHttpNotifier(config) {
111
155
  break;
112
156
  }
113
157
  }
114
- cache.set(config, resolved);
115
158
  return resolved;
116
159
  }
@@ -3,10 +3,22 @@ import type { DurableQuestionHandler } from '../agent-config.js';
3
3
  export declare class SlackNotifier implements DurableApprover, DurableQuestionHandler {
4
4
  private readonly botToken;
5
5
  private readonly channelId;
6
+ private readonly publicUrl?;
7
+ private readonly buttons;
8
+ /** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
9
+ * wins when set) — when known, each message links to a decision page
10
+ * (core/approval-links.ts) anyone in the channel can open. `buttons`:
11
+ * whether to add interactive Approve/Deny buttons, which only work with
12
+ * a Slack interactivity endpoint deployed (examples/notifier-handler/
13
+ * slack.ts); default true, false for a console-configured notifier,
14
+ * which relies on the link instead. */
6
15
  constructor(options: {
7
16
  botToken: string;
8
17
  channelId: string;
18
+ publicUrl?: string;
19
+ buttons?: boolean;
9
20
  });
21
+ private decideLine;
10
22
  requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
11
23
  pendingId: string;
12
24
  };
@@ -19,3 +31,12 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
19
31
  * so nothing to attach buttons to. */
20
32
  postMessage(text: string, blocks?: unknown[]): void;
21
33
  }
34
+ /** One plain chat.postMessage, awaited, reporting whether Slack accepted
35
+ * it — backs the console's "Send test" for a Slack notifier, where
36
+ * SlackNotifier.postMessage's fire-and-forget contract would hide the
37
+ * answer (a bad token, a channel the bot isn't in). */
38
+ export declare function sendTestSlackMessage(botToken: string, channelId: string, text: string): Promise<{
39
+ ok: boolean;
40
+ status?: number;
41
+ error?: string;
42
+ }>;
@@ -24,12 +24,29 @@
24
24
  // it's Slack-specific (the bot token, the channel, the chat.postMessage
25
25
  // call shape).
26
26
  import { randomUUID } from 'node:crypto';
27
+ import { decidePath } from '../approval-links.js';
28
+ import { publicBaseUrl } from './webhook.js';
27
29
  export class SlackNotifier {
28
30
  botToken;
29
31
  channelId;
32
+ publicUrl;
33
+ buttons;
34
+ /** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
35
+ * wins when set) — when known, each message links to a decision page
36
+ * (core/approval-links.ts) anyone in the channel can open. `buttons`:
37
+ * whether to add interactive Approve/Deny buttons, which only work with
38
+ * a Slack interactivity endpoint deployed (examples/notifier-handler/
39
+ * slack.ts); default true, false for a console-configured notifier,
40
+ * which relies on the link instead. */
30
41
  constructor(options) {
31
42
  this.botToken = options.botToken;
32
43
  this.channelId = options.channelId;
44
+ this.publicUrl = options.publicUrl;
45
+ this.buttons = options.buttons ?? true;
46
+ }
47
+ decideLine(pendingId, kind) {
48
+ const base = publicBaseUrl(this.publicUrl);
49
+ return base ? `\n<${base}${decidePath(pendingId, kind)}|${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}>` : '';
33
50
  }
34
51
  requestDurableApproval(tool, args, scope, reason) {
35
52
  const pendingId = randomUUID();
@@ -38,16 +55,18 @@ export class SlackNotifier {
38
55
  type: 'section',
39
56
  text: {
40
57
  type: 'mrkdwn',
41
- text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}`,
58
+ 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')}`,
42
59
  },
43
60
  },
44
- {
45
- type: 'actions',
46
- elements: [
47
- { type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', action_id: 'approve', value: pendingId },
48
- { type: 'button', text: { type: 'plain_text', text: 'Deny' }, style: 'danger', action_id: 'deny', value: pendingId },
49
- ],
50
- },
61
+ ...(this.buttons
62
+ ? [{
63
+ type: 'actions',
64
+ elements: [
65
+ { type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', action_id: 'approve', value: pendingId },
66
+ { type: 'button', text: { type: 'plain_text', text: 'Deny' }, style: 'danger', action_id: 'deny', value: pendingId },
67
+ ],
68
+ }]
69
+ : []),
51
70
  ]);
52
71
  return { pendingId };
53
72
  }
@@ -60,8 +79,10 @@ export class SlackNotifier {
60
79
  action_id: 'answer_option',
61
80
  }));
62
81
  this.postMessage(`Question from ${agent}: ${question}`, [
63
- { type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}` } },
64
- { type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] },
82
+ { type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}${this.decideLine(pendingId, 'question')}` } },
83
+ ...(this.buttons
84
+ ? [{ type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] }]
85
+ : []),
65
86
  ]);
66
87
  return { pendingId };
67
88
  }
@@ -89,3 +110,21 @@ export class SlackNotifier {
89
110
  });
90
111
  }
91
112
  }
113
+ /** One plain chat.postMessage, awaited, reporting whether Slack accepted
114
+ * it — backs the console's "Send test" for a Slack notifier, where
115
+ * SlackNotifier.postMessage's fire-and-forget contract would hide the
116
+ * answer (a bad token, a channel the bot isn't in). */
117
+ export async function sendTestSlackMessage(botToken, channelId, text) {
118
+ try {
119
+ const res = await fetch('https://slack.com/api/chat.postMessage', {
120
+ method: 'POST',
121
+ headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
122
+ body: JSON.stringify({ channel: channelId, text }),
123
+ });
124
+ const body = (await res.json());
125
+ return body.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: body.error ?? `HTTP ${res.status}` };
126
+ }
127
+ catch (err) {
128
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
129
+ }
130
+ }
@@ -1,11 +1,26 @@
1
1
  import type { DurableApprover, Scope } from 'actauth';
2
2
  import type { DurableQuestionHandler } from '../agent-config.js';
3
+ export interface DeliveryResult {
4
+ ok: boolean;
5
+ status?: number;
6
+ error?: string;
7
+ }
8
+ /** Where this server is reachable: LOOPENGINE_PUBLIC_URL (e.g.
9
+ * https://acme.loopengine.app) when set, read per call so a change takes
10
+ * effect without a restart, else `fallback` (a notifier.yml's own
11
+ * publicUrl, remembered from the console). undefined when neither is
12
+ * known, in which case payloads carry only relative paths. */
13
+ export declare function publicBaseUrl(fallback?: string): string | undefined;
3
14
  export declare class WebhookNotifier implements DurableApprover, DurableQuestionHandler {
4
15
  private readonly webhookUrl;
5
16
  private readonly signingSecret;
17
+ private readonly retryDelaysMs;
18
+ private readonly publicUrl?;
6
19
  constructor(options: {
7
20
  webhookUrl: string;
8
21
  signingSecret: string;
22
+ retryDelaysMs?: number[];
23
+ publicUrl?: string;
9
24
  });
10
25
  requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
11
26
  pendingId: string;
@@ -13,15 +28,9 @@ export declare class WebhookNotifier implements DurableApprover, DurableQuestion
13
28
  notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
14
29
  pendingId: string;
15
30
  };
16
- /** Shared by both methods above — the one place either used to
17
- * independently build the same signed fetch() call. Not awaited by its
18
- * own callers, same fire-and-forget contract each had on its own: both
19
- * requestDurableApproval/notifyPendingQuestion return immediately, by
20
- * contract (see DurableApprover/DurableQuestionHandler's own doc
21
- * comments), so a delivery failure here has no synchronous way to
22
- * surface to the caller — logged instead of thrown, since throwing
23
- * would blow up a decision that has already, correctly, been recorded
24
- * as pending. */
25
- private post;
26
31
  }
27
32
  export declare function postLifecycleWebhook(webhookUrl: string, webhookSecret: string, event: 'run_start' | 'run_finish', context: Record<string, unknown>): void;
33
+ /** One signed `{event: "test"}` POST (lifecycle signature header), no
34
+ * retries, awaited — backs the console's "Send test" button so an
35
+ * operator sees right away whether the receiver accepts it. */
36
+ export declare function sendTestWebhook(webhookUrl: string, webhookSecret: string, agent: string): Promise<DeliveryResult>;