loopengine 0.1.67 → 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,11 +171,20 @@ export const config: AgentConfig = {
171
171
  }
172
172
  ```
173
173
 
174
- Or skip the code: in the console, **Permissions → Approvals** sets a
174
+ Or skip the code: in the console, the Permissions tab's **Where approval
175
+ requests and questions are sent** section sets a
175
176
  webhook, Slack or Lark target for one agent (saved to
176
177
  `agents/<name>/notifier.yml`, with its secret in the agent's own `.env`),
177
178
  sends a test, shows recent deliveries, and lists every decision still
178
- waiting with Approve/Deny buttons.
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.
179
188
 
180
189
  For a third-party app calling an agent over plain HTTP:
181
190
 
@@ -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
+ }
@@ -65,6 +65,10 @@ export interface CheckpointStore {
65
65
  * first — backs GET /agents/:name/pending. Reads without locking: a
66
66
  * snapshot for display, not something to act on without withCheckpoint. */
67
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>;
68
72
  close(): Promise<void>;
69
73
  }
70
74
  /** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
@@ -82,6 +86,7 @@ export declare class FileCheckpointStore implements CheckpointStore {
82
86
  checkpoint: TurnCheckpoint | undefined;
83
87
  result: T;
84
88
  }>): Promise<T>;
89
+ get(pendingId: string): Promise<TurnCheckpoint | undefined>;
85
90
  listOpen(): Promise<TurnCheckpoint[]>;
86
91
  close(): Promise<void>;
87
92
  }
@@ -106,6 +111,7 @@ export declare class RedisCheckpointStore implements CheckpointStore {
106
111
  checkpoint: TurnCheckpoint | undefined;
107
112
  result: T;
108
113
  }>): Promise<T>;
114
+ get(pendingId: string): Promise<TurnCheckpoint | undefined>;
109
115
  listOpen(): Promise<TurnCheckpoint[]>;
110
116
  close(): Promise<void>;
111
117
  }
@@ -72,6 +72,16 @@ export class FileCheckpointStore {
72
72
  return result;
73
73
  });
74
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
+ }
75
85
  async listOpen() {
76
86
  const open = [];
77
87
  for (const name of readdirSync(this.checkpointsDir)) {
@@ -164,6 +174,16 @@ export class RedisCheckpointStore {
164
174
  return result;
165
175
  });
166
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
+ }
167
187
  async listOpen() {
168
188
  const open = [];
169
189
  let cursor = '0';
@@ -84,11 +84,11 @@ function buildResolved(httpNotifier) {
84
84
  let resolved;
85
85
  switch (httpNotifier.channel) {
86
86
  case 'webhook': {
87
- const { webhookUrl, webhookSecret } = httpNotifier.config;
87
+ const { webhookUrl, webhookSecret, publicUrl } = httpNotifier.config;
88
88
  // One shared instance for both concerns, same "it's channel-
89
89
  // specific, not concern-specific" reasoning every other channel's
90
90
  // own file already has — see WebhookNotifier's own doc comment.
91
- const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret });
91
+ const webhook = new WebhookNotifier({ webhookUrl, signingSecret: webhookSecret, publicUrl });
92
92
  resolved = {
93
93
  approver: events.has('approval') ? webhook : undefined,
94
94
  questionHandler: events.has('question') ? webhook : undefined,
@@ -98,13 +98,13 @@ function buildResolved(httpNotifier) {
98
98
  break;
99
99
  }
100
100
  case 'slack': {
101
- const { botToken, channelId } = httpNotifier.config;
101
+ const { botToken, channelId, publicUrl, buttons } = httpNotifier.config;
102
102
  // One shared instance for every concern this httpNotifier actually
103
103
  // covers — same "it's Slack-specific, not approval/question/
104
104
  // lifecycle-specific" reasoning SlackNotifier's own doc
105
105
  // comment works out; cheap to construct (holds two strings, does no
106
106
  // I/O itself) so there's no reason to build one per event kind.
107
- const slack = new SlackNotifier({ botToken, channelId });
107
+ const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons });
108
108
  resolved = {
109
109
  approver: events.has('approval') ? slack : undefined,
110
110
  questionHandler: events.has('question') ? slack : undefined,
@@ -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
  };
@@ -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
  }
@@ -5,14 +5,22 @@ export interface DeliveryResult {
5
5
  status?: number;
6
6
  error?: string;
7
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;
8
14
  export declare class WebhookNotifier implements DurableApprover, DurableQuestionHandler {
9
15
  private readonly webhookUrl;
10
16
  private readonly signingSecret;
11
17
  private readonly retryDelaysMs;
18
+ private readonly publicUrl?;
12
19
  constructor(options: {
13
20
  webhookUrl: string;
14
21
  signingSecret: string;
15
22
  retryDelaysMs?: number[];
23
+ publicUrl?: string;
16
24
  });
17
25
  requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
18
26
  pendingId: string;
@@ -48,18 +48,24 @@
48
48
  // working unchanged.
49
49
  import { createHmac, randomUUID } from 'node:crypto';
50
50
  import { recordDelivery } from '../notifier-deliveries.js';
51
+ import { decidePath } from '../approval-links.js';
51
52
  /** Waits before each retry after the first attempt — four tries in all,
52
53
  * spread over about 40 seconds. */
53
54
  const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
54
55
  function sign(body, secret) {
55
56
  return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
56
57
  }
57
- /** LOOPENGINE_PUBLIC_URL (e.g. https://acme.loopengine.app), read per
58
- * call so a change takes effect without a restart; undefined when unset,
59
- * in which case payloads carry only the relative path. */
60
- function publicUrl(path) {
61
- const base = process.env.LOOPENGINE_PUBLIC_URL;
62
- return base ? base.replace(/\/+$/, '') + path : undefined;
58
+ /** Where this server is reachable: LOOPENGINE_PUBLIC_URL (e.g.
59
+ * https://acme.loopengine.app) when set, read per call so a change takes
60
+ * effect without a restart, else `fallback` (a notifier.yml's own
61
+ * publicUrl, remembered from the console). undefined when neither is
62
+ * known, in which case payloads carry only relative paths. */
63
+ export function publicBaseUrl(fallback) {
64
+ const base = process.env.LOOPENGINE_PUBLIC_URL || fallback;
65
+ return base ? base.replace(/\/+$/, '') : undefined;
66
+ }
67
+ function absolute(base, path) {
68
+ return base ? base + path : undefined;
63
69
  }
64
70
  async function attemptOnce(url, body, headers) {
65
71
  try {
@@ -91,18 +97,24 @@ export class WebhookNotifier {
91
97
  webhookUrl;
92
98
  signingSecret;
93
99
  retryDelaysMs;
100
+ publicUrl;
94
101
  constructor(options) {
95
102
  this.webhookUrl = options.webhookUrl;
96
103
  this.signingSecret = options.signingSecret;
97
104
  this.retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
105
+ this.publicUrl = options.publicUrl;
98
106
  }
99
107
  requestDurableApproval(tool, args, scope, reason) {
100
108
  const pendingId = randomUUID();
101
109
  const requestedAt = new Date().toISOString();
102
110
  const resolvePath = `/pending-approvals/${pendingId}/resolve`;
111
+ const decide = decidePath(pendingId, 'approval');
112
+ const base = publicBaseUrl(this.publicUrl);
103
113
  const body = JSON.stringify({
104
114
  pendingId, tool, args, scope, reason, requestedAt,
105
- kind: 'approval', agent: scope.agent, resolvePath, resolveUrl: publicUrl(resolvePath),
115
+ kind: 'approval', agent: scope.agent, resolvePath, resolveUrl: absolute(base, resolvePath),
116
+ // A signed link a person can open to decide, no login needed.
117
+ decidePath: decide, decideUrl: absolute(base, decide),
106
118
  });
107
119
  void deliver(this.webhookUrl, body, { 'X-Actauth-Signature': sign(body, this.signingSecret) }, { agent: scope.agent, kind: 'approval', pendingId }, this.retryDelaysMs);
108
120
  return { pendingId };
@@ -111,9 +123,12 @@ export class WebhookNotifier {
111
123
  const pendingId = randomUUID();
112
124
  const requestedAt = new Date().toISOString();
113
125
  const answerPath = `/pending-questions/${pendingId}/answer`;
126
+ const decide = decidePath(pendingId, 'question');
127
+ const base = publicBaseUrl(this.publicUrl);
114
128
  const body = JSON.stringify({
115
129
  pendingId, question, options, agent, sessionId, requestedAt,
116
- kind: 'question', answerPath, answerUrl: publicUrl(answerPath),
130
+ kind: 'question', answerPath, answerUrl: absolute(base, answerPath),
131
+ decidePath: decide, decideUrl: absolute(base, decide),
117
132
  });
118
133
  void deliver(this.webhookUrl, body, { 'X-Askuser-Signature': sign(body, this.signingSecret) }, { agent, kind: 'question', pendingId }, this.retryDelaysMs);
119
134
  return { pendingId };
@@ -10,6 +10,10 @@ export interface NotifierFile {
10
10
  /** lark: app id and chat to post into. */
11
11
  appId?: string;
12
12
  chatId?: string;
13
+ /** Where this server is reachable, for the links in each message —
14
+ * remembered from the console's own address when saving.
15
+ * LOOPENGINE_PUBLIC_URL, when set, wins over it. */
16
+ publicUrl?: string;
13
17
  }
14
18
  /** The env var each channel's secret is read from, per agent. */
15
19
  export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
@@ -1,6 +1,6 @@
1
1
  // agents/<name>/notifier.yml — where an agent's "ask" approvals and
2
2
  // questions go when nobody is watching a live chat, set from the console
3
- // (Permissions → Approvals) instead of in code. Only consulted when the
3
+ // (Permissions → "Where approval requests and questions are sent") instead of in code. Only consulted when the
4
4
  // agent's own AgentConfig.httpNotifier is unset; code always wins.
5
5
  //
6
6
  // The file holds only non-secret settings. The one secret each channel
@@ -38,6 +38,12 @@ export function validateNotifierFile(input) {
38
38
  }
39
39
  const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
40
40
  const file = { channel, events: events };
41
+ const publicUrl = str('publicUrl');
42
+ if (publicUrl) {
43
+ if (!/^https?:\/\/[^/]+/.test(publicUrl))
44
+ throw new NotifierFileError('publicUrl must be an http(s) URL');
45
+ file.publicUrl = publicUrl.replace(/\/+$/, '');
46
+ }
41
47
  if (channel === 'webhook') {
42
48
  const url = str('url');
43
49
  if (!url || !/^https?:\/\//.test(url))
@@ -69,8 +75,8 @@ export function readNotifierFile(dir) {
69
75
  }
70
76
  export function writeNotifierFile(dir, input) {
71
77
  const file = validateNotifierFile(input);
72
- const header = '# Where this agent sends "ask" approvals and questions when nobody is in a live chat.\n' +
73
- `# Edited from the console (Permissions > Approvals). The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[file.channel]}.\n`;
78
+ const header = '# Where this agent sends approval requests and questions when it is called over HTTP.\n' +
79
+ `# Edited from the console (Permissions tab). The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[file.channel]}.\n`;
74
80
  writeFileSync(notifierFilePath(dir), header + stringifyYaml(file));
75
81
  return file;
76
82
  }
@@ -87,9 +93,11 @@ export function notifierConfigFromFile(file, env) {
87
93
  return { missingSecret: secretVar };
88
94
  switch (file.channel) {
89
95
  case 'webhook':
90
- return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret }, events: file.events } };
96
+ return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret, publicUrl: file.publicUrl }, events: file.events } };
91
97
  case 'slack':
92
- return { config: { channel: 'slack', config: { botToken: secret, channelId: file.channelId }, events: file.events } };
98
+ // No interactive buttons: a console-configured Slack notifier has
99
+ // no interactivity endpoint behind it; the decision link replaces them.
100
+ return { config: { channel: 'slack', config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
93
101
  case 'lark':
94
102
  return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId }, events: file.events } };
95
103
  }
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
10
10
  export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } from '#core/notifier-file.js';
11
11
  export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
12
12
  export { describeNotifier } from '#core/http-notifier.js';
13
+ export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
14
+ export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
13
15
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
14
16
  export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, type DefaultWebhook } from './web/notifier-admin.js';
15
17
  export type * from './core/loop-events.js';
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ export { setUsageSink, recordUsage } from '#core/usage.js';
12
12
  export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
13
13
  export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
14
14
  export { describeNotifier } from '#core/http-notifier.js';
15
+ export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
16
+ export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
15
17
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
16
18
  export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending } from './web/notifier-admin.js';
17
19
  // The framework-agnostic browser client for adapters/http.ts's own wire
@@ -39,6 +39,7 @@ export const agentsConfigPageHtml = `<!doctype html>
39
39
  panels' own content. */
40
40
  #empty { color: var(--ink-muted); font-size: 13px; padding: 24px; }
41
41
  .env-cell { display: grid; gap: 6px; }
42
+ .approvals-nudge { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--ink); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 24px; font-size: 13px; }
42
43
  .env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
43
44
  form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
44
45
  form.add-source.env-inline input, form.add-source.env-inline select { flex: 1 1 140px; width: auto; min-width: 0; }
@@ -955,7 +956,7 @@ ${consoleShellScript}
955
956
  '</form></section>';
956
957
  }
957
958
 
958
- // ---- Permissions → Approvals: where "ask" decisions go when nobody is
959
+ // ---- Permissions → "Where approval requests and questions are sent": where "ask" decisions go when nobody is
959
960
  // in Chat (web/notifier-admin.ts behind GET/PUT/DELETE .../notifier),
960
961
  // the decisions still waiting (GET .../pending), and recent webhook
961
962
  // deliveries. Reloaded every time the tab opens, so the waiting list is
@@ -980,6 +981,9 @@ ${consoleShellScript}
980
981
  ? '<form class="pending-answer" data-id="' + escapeHtml(p.pendingId) + '" style="display:flex;gap:6px"><input name="answer" placeholder="Answer" required style="min-width:0"><button type="submit">Answer</button></form>'
981
982
  : '<div style="display:flex;gap:6px"><button type="button" class="primary pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="approve">Approve</button>' +
982
983
  '<button type="button" class="pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="deny">Deny</button></div>';
984
+ if (p.decidePath) {
985
+ actions += '<button type="button" class="pending-copy" data-path="' + escapeHtml(p.decidePath) + '" title="A link anyone can open to decide, no login needed" style="margin-top:6px">Copy link</button>';
986
+ }
983
987
  return '<tr><td><span class="le-pill ' + (p.kind === 'question' ? 'accent' : 'ask') + '">' + (p.kind === 'question' ? 'question' : 'approval') + '</span>' +
984
988
  (p.createdAt ? '<div class="hint">' + escapeHtml(new Date(p.createdAt).toLocaleString()) + '</div>' : '') + '</td>' +
985
989
  '<td>' + details + '</td><td><code>' + escapeHtml(p.sessionId) + '</code></td><td>' + actions + '</td></tr>';
@@ -991,12 +995,15 @@ ${consoleShellScript}
991
995
  if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
992
996
  if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
993
997
  if (n.source === 'default') return 'Using the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + '). Set one below to use a different target for this agent.';
994
- return 'Not set up. A call that needs approval only reaches someone in Chat; set a webhook or Slack channel so apps calling this agent over HTTP can get approvals.';
998
+ return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a webhook, Slack channel or Lark chat below.';
995
999
  }
996
1000
 
997
1001
  function renderNotifierForm(n) {
998
1002
  var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
999
1003
  var events = f.events || [];
1004
+ function eventBox(e) {
1005
+ return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
1006
+ }
1000
1007
  function field(channel, html) {
1001
1008
  return '<div class="notifier-fields" data-channel="' + channel + '"' + (f.channel === channel ? '' : ' hidden') + '>' + html + '</div>';
1002
1009
  }
@@ -1013,15 +1020,18 @@ ${consoleShellScript}
1013
1020
  field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret')) +
1014
1021
  field('slack', '<label>Channel ID<input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' + secretField('slack', 'Bot token')) +
1015
1022
  field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
1016
- '<div class="body-field">Send<div style="display:flex;gap:14px;flex-wrap:wrap">' +
1017
- NOTIFIER_EVENTS.map(function (e) {
1018
- return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
1019
- }).join('') + '</div></div>' +
1020
- '<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit">Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
1023
+ '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
1024
+ '<input type="url" name="publicUrl" id="notifierPublicUrl" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
1025
+ '<div class="body-field">Send' +
1026
+ '<div style="display:flex;gap:14px;flex-wrap:wrap">' + NOTIFIER_EVENTS.slice(0, 2).map(eventBox).join('') + '</div>' +
1027
+ '<details' + (events.indexOf('run_finish') !== -1 || events.indexOf('run_start') !== -1 ? ' open' : '') + '><summary>More events</summary>' +
1028
+ '<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">' + NOTIFIER_EVENTS.slice(2).map(eventBox).join('') + '</div>' +
1029
+ '<p class="hint" style="margin:4px 0 0">Run finished tells your app the agent&#39;s final reply once a decision lets it finish.</p></details></div>' +
1030
+ (n.canSave === false ? '<p class="error" style="margin:0">Saving needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server, because this stores a secret. Add <code>LOOPENGINE_ADMIN_AUTH=user:pass</code> to <code>.env</code> and restart.</p>' : '') +
1031
+ '<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit"' + (n.canSave === false ? ' disabled' : '') + '>Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
1021
1032
  (n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
1022
1033
  '<div id="notifierResult" class="hint"></div>' +
1023
- '<p class="hint" style="margin:0">Your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries <code>pendingId</code> and <code>resolvePath</code>; your app sends <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> there.' +
1024
- (n.publicUrl ? '' : ' Set <code>LOOPENGINE_PUBLIC_URL</code> to include a full <code>resolveUrl</code> too.') + '</p>' +
1034
+ '<p class="hint" style="margin:0">For a webhook: your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>' +
1025
1035
  '</form>';
1026
1036
  }
1027
1037
 
@@ -1034,14 +1044,35 @@ ${consoleShellScript}
1034
1044
  }).join('') + '</tbody></table></div>';
1035
1045
  }
1036
1046
 
1037
- // Shown at the top of the tab, and only when something is waiting.
1038
- function renderPendingHtml(pending) {
1039
- if (!pending.length) return '';
1040
- return '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
1047
+ // Tools that can stop for approval: a rule that asks, or "ask" as the
1048
+ // decision when no rule matches.
1049
+ function askingTools() {
1050
+ var perms = currentCfg && currentCfg.permissions;
1051
+ if (!perms) return [];
1052
+ var tools = [];
1053
+ perms.rules.forEach(function (r) { if (r.decision === 'ask' && tools.indexOf(r.tool) === -1) tools.push(r.tool); });
1054
+ if (perms.defaultDecision === 'ask') tools.push('any tool without a rule');
1055
+ return tools;
1056
+ }
1057
+
1058
+ // The top of the tab: a nudge when approvals over HTTP would reach
1059
+ // nobody, and whatever is waiting right now.
1060
+ function renderPendingHtml(pending, n) {
1061
+ var html = '';
1062
+ var tools = askingTools();
1063
+ if (tools.length && n && n.source === 'none') {
1064
+ html += '<div class="approvals-nudge"><strong>Approval requests aren&#39;t sent anywhere yet.</strong> ' +
1065
+ 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. ' +
1066
+ '<a href="#approvalsSetup" class="approvals-nudge-link">Choose where they go</a></div>';
1067
+ }
1068
+ if (pending.length) html += '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
1069
+ return html;
1041
1070
  }
1042
1071
 
1043
1072
  function renderApprovalsHtml(n) {
1044
- return '<section><h3>Approvals when nobody is in Chat</h3><p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
1073
+ return '<section id="approvalsSetup"><h3>Where approval requests and questions are sent</h3>' +
1074
+ '<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>' +
1075
+ '<p class="muted" style="margin:0 0 12px">Currently: ' + renderNotifierStatus(n) + '</p>' +
1045
1076
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
1046
1077
  (n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
1047
1078
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
@@ -1050,6 +1081,24 @@ ${consoleShellScript}
1050
1081
  function wirePendingHandlers(name) {
1051
1082
  var content = detail.querySelector('#pendingContent');
1052
1083
  if (!content) return;
1084
+ var nudgeLink = content.querySelector('.approvals-nudge-link');
1085
+ if (nudgeLink) {
1086
+ nudgeLink.addEventListener('click', function (ev) {
1087
+ ev.preventDefault();
1088
+ var target = detail.querySelector('#approvalsSetup');
1089
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1090
+ });
1091
+ }
1092
+ var copyBtns = content.querySelectorAll('.pending-copy');
1093
+ for (var c = 0; c < copyBtns.length; c++) {
1094
+ copyBtns[c].addEventListener('click', function (ev) {
1095
+ var btn = ev.currentTarget;
1096
+ var link = location.origin + btn.dataset.path;
1097
+ var done = function () { btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = 'Copy link'; }, 1500); };
1098
+ if (navigator.clipboard) navigator.clipboard.writeText(link).then(done, function () { window.prompt('Copy this link', link); });
1099
+ else window.prompt('Copy this link', link);
1100
+ });
1101
+ }
1053
1102
  var decideBtns = content.querySelectorAll('.pending-decide');
1054
1103
  for (var i = 0; i < decideBtns.length; i++) {
1055
1104
  decideBtns[i].addEventListener('click', function (ev) {
@@ -1097,7 +1146,7 @@ ${consoleShellScript}
1097
1146
  ev.preventDefault();
1098
1147
  var data = new FormData(form);
1099
1148
  var channel = data.get('channel');
1100
- var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId') };
1149
+ var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
1101
1150
  var secret = data.get('secret_' + channel);
1102
1151
  if (secret) body.secret = secret;
1103
1152
  result.textContent = 'Saving…';
@@ -1137,7 +1186,7 @@ ${consoleShellScript}
1137
1186
  wireApprovalsHandlers(name);
1138
1187
  var pendingEl = detail.querySelector('#pendingContent');
1139
1188
  if (pendingEl) {
1140
- pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || []);
1189
+ pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || [], results[0].body);
1141
1190
  wirePendingHandlers(name);
1142
1191
  }
1143
1192
  if (message) { var r = content.querySelector('#notifierResult'); if (r) r.textContent = message; }
@@ -0,0 +1,16 @@
1
+ export interface DecidePageItem {
2
+ kind: 'approval' | 'question';
3
+ agent: string;
4
+ tool: string;
5
+ args: Record<string, unknown>;
6
+ reason: string;
7
+ sessionId: string;
8
+ tenant: string;
9
+ createdAt?: string;
10
+ expiresAt: number;
11
+ }
12
+ /** A link that's malformed, tampered with, or past its expiry. */
13
+ export declare function renderDecideInvalidPage(): string;
14
+ /** A valid link whose decision has already been made. */
15
+ export declare function renderDecideGonePage(): string;
16
+ export declare function renderDecidePage(item: DecidePageItem): string;
@@ -0,0 +1,138 @@
1
+ // The page a decision link opens (GET /decide/<token>, see
2
+ // core/approval-links.ts): what's waiting — the tool and its arguments,
3
+ // or the question — and buttons to approve, deny or answer. Anyone with
4
+ // the link can decide; the signed token is the only credential, so this
5
+ // page shows nothing beyond the one decision it names.
6
+ //
7
+ // Rendered on the server; the small script only posts the choice back to
8
+ // the same URL (POST /decide/<token>) and shows the outcome. Written
9
+ // without backticks or backslashes inside the script, same convention as
10
+ // the other embedded pages.
11
+ import { devUiCss } from './dev-ui-styles.js';
12
+ function esc(s) {
13
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
14
+ }
15
+ const PAGE_CSS = `
16
+ body { display: grid; place-items: start center; padding: 48px 16px; background: var(--surface); }
17
+ main { width: 100%; max-width: 560px; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 24px; display: grid; gap: 16px; }
18
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--ink-muted); font-size: 13px; }
19
+ .mark { width: 18px; height: 18px; border-radius: 5px; background: var(--accent); }
20
+ h1 { font-size: 20px; margin: 0; }
21
+ dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; font-size: 13px; }
22
+ dt { color: var(--ink-faint); }
23
+ dd { margin: 0; overflow-wrap: anywhere; }
24
+ pre, textarea { font-family: var(--font-mono); font-size: 12.5px; background: var(--code-bg); border-radius: 8px; padding: 12px; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
25
+ textarea { width: 100%; min-height: 120px; border: 1px solid var(--line); }
26
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; }
27
+ .actions button { padding: 8px 16px; }
28
+ .deny { color: var(--bad); }
29
+ .note { font-size: 12px; color: var(--ink-faint); margin: 0; }
30
+ .result { padding: 12px 14px; border-radius: 8px; background: var(--good-soft); color: var(--good); }
31
+ .result.bad { background: var(--bad-soft); color: var(--bad); }
32
+ .reply { white-space: pre-wrap; background: var(--surface); border-radius: 8px; padding: 12px 14px; }
33
+ details summary { cursor: pointer; font-size: 13px; color: var(--ink-muted); }
34
+ `;
35
+ function shell(title, body, script = '') {
36
+ return `<!doctype html>
37
+ <html lang="en">
38
+ <head>
39
+ <meta charset="utf-8">
40
+ <meta name="viewport" content="width=device-width, initial-scale=1">
41
+ <meta name="robots" content="noindex">
42
+ <title>${esc(title)}</title>
43
+ <style>${devUiCss}${PAGE_CSS}</style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <div class="brand"><span class="mark" aria-hidden="true"></span>LoopEngine</div>
48
+ ${body}
49
+ </main>
50
+ ${script}
51
+ </body>
52
+ </html>`;
53
+ }
54
+ /** A link that's malformed, tampered with, or past its expiry. */
55
+ export function renderDecideInvalidPage() {
56
+ return shell('Link not valid', '<h1>This link isn&#39;t valid</h1><p class="muted">It may have expired or been copied incompletely. Ask for a new one, or decide from the agent&#39;s Permissions tab.</p>');
57
+ }
58
+ /** A valid link whose decision has already been made. */
59
+ export function renderDecideGonePage() {
60
+ return shell('Already decided', '<h1>Already decided</h1><p class="muted">Someone has already made this decision, so there&#39;s nothing left to do here.</p>');
61
+ }
62
+ const SCRIPT = `<script>
63
+ (function () {
64
+ var main = document.querySelector('main');
65
+ var result = document.getElementById('result');
66
+ function send(body, button) {
67
+ var buttons = main.querySelectorAll('button');
68
+ for (var i = 0; i < buttons.length; i++) buttons[i].disabled = true;
69
+ result.hidden = false;
70
+ result.className = 'result';
71
+ result.textContent = 'Sending' + String.fromCharCode(8230);
72
+ fetch(location.pathname, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
73
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
74
+ .then(function (r) {
75
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
76
+ var form = document.getElementById('decision');
77
+ if (form) form.hidden = true;
78
+ result.textContent = r.body.message || (body.decision === 'deny' ? 'Denied.' : body.answer !== undefined ? 'Answer sent.' : 'Approved.');
79
+ if (r.body.text) {
80
+ var reply = document.createElement('div');
81
+ reply.className = 'reply';
82
+ reply.textContent = r.body.text;
83
+ result.after(reply);
84
+ }
85
+ })
86
+ .catch(function (err) {
87
+ result.className = 'result bad';
88
+ result.textContent = err.message;
89
+ for (var i = 0; i < buttons.length; i++) buttons[i].disabled = false;
90
+ });
91
+ }
92
+ main.addEventListener('click', function (ev) {
93
+ var btn = ev.target.closest('button[data-send]');
94
+ if (!btn) return;
95
+ var payload = JSON.parse(btn.getAttribute('data-send'));
96
+ if (payload.editedFrom) {
97
+ try { payload = { decision: 'approve', editedArgs: JSON.parse(document.getElementById(payload.editedFrom).value) }; }
98
+ catch (e) { result.hidden = false; result.className = 'result bad'; result.textContent = 'The arguments aren' + String.fromCharCode(39) + 't valid JSON.'; return; }
99
+ }
100
+ if (payload.answerFrom) {
101
+ var text = document.getElementById(payload.answerFrom).value.trim();
102
+ if (!text) return;
103
+ payload = { answer: text };
104
+ }
105
+ send(payload, btn);
106
+ });
107
+ })();
108
+ </script>`;
109
+ export function renderDecidePage(item) {
110
+ const expires = new Date(item.expiresAt).toUTCString();
111
+ const facts = `<dl><dt>Agent</dt><dd>${esc(item.agent)}</dd>` +
112
+ `<dt>Conversation</dt><dd><code>${esc(item.sessionId)}</code>${item.tenant && item.tenant !== 'default' ? ` (tenant ${esc(item.tenant)})` : ''}</dd>` +
113
+ (item.createdAt ? `<dt>Asked</dt><dd>${esc(new Date(item.createdAt).toUTCString())}</dd>` : '') +
114
+ `</dl>`;
115
+ const note = `<p class="note">Anyone with this link can make this decision. It works once and expires ${esc(expires)}.</p>`;
116
+ const result = '<div id="result" class="result" hidden role="status"></div>';
117
+ if (item.kind === 'question') {
118
+ const question = typeof item.args.question === 'string' ? item.args.question : item.reason;
119
+ const options = Array.isArray(item.args.options) ? item.args.options.map(String) : [];
120
+ const optionButtons = options.map((o) => `<button type="button" data-send="${esc(JSON.stringify({ answer: o }))}">${esc(o)}</button>`).join('');
121
+ return shell(`Question from ${item.agent}`, `<h1>${esc(item.agent)} has a question</h1>${facts}<pre>${esc(question)}</pre>` +
122
+ `<div id="decision" style="display:grid;gap:10px">` +
123
+ (optionButtons ? `<div class="actions">${optionButtons}</div>` : '') +
124
+ `<label style="display:grid;gap:6px;font-size:13px">${options.length ? 'Or answer in your own words' : 'Your answer'}<textarea id="answerText" style="min-height:80px;font-family:var(--font-sans)"></textarea></label>` +
125
+ `<div class="actions"><button type="button" class="primary" data-send="${esc(JSON.stringify({ answerFrom: 'answerText' }))}">Send answer</button></div></div>` +
126
+ result + note, SCRIPT);
127
+ }
128
+ return shell(`Approve ${item.tool}?`, `<h1>Approve <code>${esc(item.tool)}</code>?</h1>${facts}` +
129
+ `<pre>${esc(JSON.stringify(item.args, null, 2))}</pre>` +
130
+ `<p class="muted" style="margin:0">${esc(item.reason)}</p>` +
131
+ `<div id="decision" style="display:grid;gap:12px">` +
132
+ `<div class="actions"><button type="button" class="primary" data-send="${esc(JSON.stringify({ decision: 'approve' }))}">Approve</button>` +
133
+ `<button type="button" class="deny" data-send="${esc(JSON.stringify({ decision: 'deny' }))}">Deny</button></div>` +
134
+ `<details><summary>Change the arguments first</summary><div style="display:grid;gap:8px;margin-top:8px">` +
135
+ `<textarea id="editedArgs">${esc(JSON.stringify(item.args, null, 2))}</textarea>` +
136
+ `<div class="actions"><button type="button" data-send="${esc(JSON.stringify({ editedFrom: 'editedArgs' }))}">Approve with these arguments</button></div></div></details>` +
137
+ `</div>` + result + note, SCRIPT);
138
+ }
@@ -39,6 +39,7 @@ export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?:
39
39
  /** Every decision still waiting for this agent, oldest first, each with
40
40
  * where to send the answer. */
41
41
  export declare function listAgentPending(store: CheckpointStore, agentName: string): Promise<({
42
+ decidePath: string;
42
43
  answerPath: string;
43
44
  pendingId: string;
44
45
  kind: "approval" | "question";
@@ -49,6 +50,7 @@ export declare function listAgentPending(store: CheckpointStore, agentName: stri
49
50
  tenant: string;
50
51
  createdAt: string | null;
51
52
  } | {
53
+ decidePath: string;
52
54
  resolvePath: string;
53
55
  pendingId: string;
54
56
  kind: "approval" | "question";
@@ -1,4 +1,5 @@
1
1
  import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
2
+ import { decidePath } from '../core/approval-links.js';
2
3
  import { describeNotifier } from '../core/http-notifier.js';
3
4
  import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
4
5
  import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
@@ -81,6 +82,8 @@ export async function listAgentPending(store, agentName) {
81
82
  tenant: checkpoint.tenant,
82
83
  createdAt: checkpoint.createdAt ?? null,
83
84
  ...(kind === 'question' ? { answerPath: `/pending-questions/${pendingId}/answer` } : { resolvePath: `/pending-approvals/${pendingId}/resolve` }),
85
+ // A signed link anyone can open to decide (core/approval-links.ts).
86
+ decidePath: decidePath(pendingId, kind),
84
87
  });
85
88
  }
86
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -48,6 +48,8 @@
48
48
  "dist/core/notifier-file.d.ts",
49
49
  "dist/core/notifier-deliveries.js",
50
50
  "dist/core/notifier-deliveries.d.ts",
51
+ "dist/core/approval-links.js",
52
+ "dist/core/approval-links.d.ts",
51
53
  "dist/core/session-store.js",
52
54
  "dist/core/session-store.d.ts",
53
55
  "dist/core/vector-index.js",