loopengine 0.1.67 → 0.1.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -2,6 +2,7 @@ import type { AgentConfig } from '../core/agent-config.js';
2
2
  import type { CheckpointStore } from '../core/durable-approvals.js';
3
3
  import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
4
4
  import { type NotifierFileChannel } from '../core/notifier-file.js';
5
+ import { type SlackChannel } from '../core/slack-interactions.js';
5
6
  /** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
6
7
  * _SECRET), which applies to the plain /messages route when an agent has
7
8
  * no notifier of its own. */
@@ -17,6 +18,12 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
17
18
  secretsSet: {
18
19
  [k: string]: boolean;
19
20
  };
21
+ slack: {
22
+ signingSecretVar: string;
23
+ signingSecretSet: boolean;
24
+ interactivityPath: string;
25
+ manifestTemplate: Record<string, unknown>;
26
+ };
20
27
  deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
21
28
  channel?: import("../core/agent-config.js").HttpNotifierConfig["channel"];
22
29
  events?: string[];
@@ -28,6 +35,17 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
28
35
  * form includes one, the channel's secret into the agent's own env.
29
36
  * Throws NotifierFileError for an invalid form. */
30
37
  export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
38
+ /** The bot token and signing secret behind an agent's Slack notifier —
39
+ * from AgentConfig.httpNotifier when it's Slack, else from the agent's
40
+ * env (the console-saved ones). The signing secret always comes from the
41
+ * agent env. */
42
+ export declare function slackCredentialsFor(config: AgentConfig): {
43
+ botToken?: string;
44
+ signingSecret?: string;
45
+ };
46
+ /** Channels for the console's picker, with the token being typed in or,
47
+ * when none is given, the one already saved. */
48
+ export declare function listAgentSlackChannels(config: AgentConfig, botToken?: string): Promise<SlackChannel[]>;
31
49
  /** Removes notifier.yml; the stored secret is left alone, so turning the
32
50
  * notifier back on doesn't need it re-entered. */
33
51
  export declare function removeAgentNotifier(config: AgentConfig): void;
@@ -39,6 +57,7 @@ export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?:
39
57
  /** Every decision still waiting for this agent, oldest first, each with
40
58
  * where to send the answer. */
41
59
  export declare function listAgentPending(store: CheckpointStore, agentName: string): Promise<({
60
+ decidePath: string;
42
61
  answerPath: string;
43
62
  pendingId: string;
44
63
  kind: "approval" | "question";
@@ -49,6 +68,7 @@ export declare function listAgentPending(store: CheckpointStore, agentName: stri
49
68
  tenant: string;
50
69
  createdAt: string | null;
51
70
  } | {
71
+ decidePath: string;
52
72
  resolvePath: string;
53
73
  pendingId: string;
54
74
  kind: "approval" | "question";
@@ -1,9 +1,11 @@
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';
5
6
  import { listDeliveries } from '../core/notifier-deliveries.js';
6
- import { NOTIFIER_SECRET_VARS, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
7
+ import { NOTIFIER_SECRET_VARS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
8
+ import { listSlackChannels, slackAppManifest } from '../core/slack-interactions.js';
7
9
  import { setAgentEnvVar } from './env-admin.js';
8
10
  export function describeAgentNotifier(config, defaultWebhook) {
9
11
  const described = describeNotifier(config);
@@ -17,6 +19,15 @@ export function describeAgentNotifier(config, defaultWebhook) {
17
19
  publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
18
20
  secretVars: NOTIFIER_SECRET_VARS,
19
21
  secretsSet,
22
+ slack: {
23
+ signingSecretVar: NOTIFIER_SLACK_SIGNING_SECRET_VAR,
24
+ signingSecretSet: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) !== undefined,
25
+ // Where the Slack app's Interactivity Request URL should point.
26
+ interactivityPath: `/hooks/slack/${encodeURIComponent(config.name)}`,
27
+ // The console swaps __INTERACTIVITY_URL__ for the link address +
28
+ // interactivityPath as you type.
29
+ manifestTemplate: slackAppManifest({ agentName: config.name, interactivityUrl: '__INTERACTIVITY_URL__' }),
30
+ },
20
31
  deliveries: listDeliveries(config.name),
21
32
  };
22
33
  }
@@ -28,6 +39,26 @@ export async function saveAgentNotifier(config, body) {
28
39
  if (typeof body.secret === 'string' && body.secret.trim()) {
29
40
  await setAgentEnvVar(config.name, NOTIFIER_SECRET_VARS[file.channel], body.secret.trim());
30
41
  }
42
+ if (file.channel === 'slack' && typeof body.signingSecret === 'string' && body.signingSecret.trim()) {
43
+ await setAgentEnvVar(config.name, NOTIFIER_SLACK_SIGNING_SECRET_VAR, body.signingSecret.trim());
44
+ }
45
+ }
46
+ /** The bot token and signing secret behind an agent's Slack notifier —
47
+ * from AgentConfig.httpNotifier when it's Slack, else from the agent's
48
+ * env (the console-saved ones). The signing secret always comes from the
49
+ * agent env. */
50
+ export function slackCredentialsFor(config) {
51
+ const env = createAgentEnv(config.name, agentDirFor(config));
52
+ const botToken = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.slack);
53
+ return { botToken, signingSecret: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) };
54
+ }
55
+ /** Channels for the console's picker, with the token being typed in or,
56
+ * when none is given, the one already saved. */
57
+ export async function listAgentSlackChannels(config, botToken) {
58
+ const token = botToken?.trim() || slackCredentialsFor(config).botToken;
59
+ if (!token)
60
+ throw new Error('Enter the bot token first');
61
+ return listSlackChannels(token);
31
62
  }
32
63
  /** Removes notifier.yml; the stored secret is left alone, so turning the
33
64
  * notifier back on doesn't need it re-entered. */
@@ -81,6 +112,8 @@ export async function listAgentPending(store, agentName) {
81
112
  tenant: checkpoint.tenant,
82
113
  createdAt: checkpoint.createdAt ?? null,
83
114
  ...(kind === 'question' ? { answerPath: `/pending-questions/${pendingId}/answer` } : { resolvePath: `/pending-approvals/${pendingId}/resolve` }),
115
+ // A signed link anyone can open to decide (core/approval-links.ts).
116
+ decidePath: decidePath(pendingId, kind),
84
117
  });
85
118
  }
86
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
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,10 @@
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",
53
+ "dist/core/slack-interactions.js",
54
+ "dist/core/slack-interactions.d.ts",
51
55
  "dist/core/session-store.js",
52
56
  "dist/core/session-store.d.ts",
53
57
  "dist/core/vector-index.js",