loopengine 0.3.8 → 0.3.9

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.
@@ -31,9 +31,13 @@ export declare function setTelegramWebhook(botToken: string, url: string, secret
31
31
  botUsername?: string;
32
32
  }>;
33
33
  /** Where the bot's updates go now, and the last delivery error Telegram saw. */
34
+ /** Where the bot's updates go now; the last delivery error Telegram saw
35
+ * and when (it keeps it until a newer one); and how many are waiting. */
34
36
  export declare function telegramWebhookInfo(botToken: string): Promise<{
35
37
  ok: boolean;
36
38
  url?: string;
37
39
  lastError?: string;
40
+ lastErrorAt?: string;
41
+ pending?: number;
38
42
  error?: string;
39
43
  }>;
@@ -121,11 +121,20 @@ export async function setTelegramWebhook(botToken, url, secret) {
121
121
  return { ok: true, botUsername: await telegramBotUsername(botToken) };
122
122
  }
123
123
  /** Where the bot's updates go now, and the last delivery error Telegram saw. */
124
+ /** Where the bot's updates go now; the last delivery error Telegram saw
125
+ * and when (it keeps it until a newer one); and how many are waiting. */
124
126
  export async function telegramWebhookInfo(botToken) {
125
127
  const info = await callTelegram(botToken, 'getWebhookInfo', {});
126
128
  if (!info.ok)
127
129
  return { ok: false, error: telegramSetupError(info.description) };
128
- return { ok: true, url: info.result?.url || undefined, ...(info.result?.last_error_message ? { lastError: info.result.last_error_message } : {}) };
130
+ const r = info.result;
131
+ return {
132
+ ok: true,
133
+ url: r?.url || undefined,
134
+ ...(r?.last_error_message ? { lastError: r.last_error_message } : {}),
135
+ ...(r?.last_error_date ? { lastErrorAt: new Date(r.last_error_date * 1000).toISOString() } : {}),
136
+ ...(r?.pending_update_count ? { pending: r.pending_update_count } : {}),
137
+ };
129
138
  }
130
139
  function telegramSetupError(description) {
131
140
  if (/unauthorized|not found/i.test(description ?? ''))
@@ -34,5 +34,9 @@ export declare function toResponsesInput(message: Message): ResponseInputItem[];
34
34
  * blocks, message text, and function calls as tool_use blocks (their
35
35
  * call_id is the id a tool result answers). */
36
36
  export declare function fromResponse(response: OpenAI.Responses.Response): ModelResponse;
37
+ /** OpenAI's reasoning models (GPT-5 on, the o-series), which reason by
38
+ * default and take a reasoning effort; older ones (gpt-4o, gpt-4.1)
39
+ * reject one. */
40
+ export declare function isOpenAIReasoningModel(model: string): boolean;
37
41
  export declare function createOpenAIModelCall(options: OpenAIModelCallOptions): ModelCall;
38
42
  export {};
@@ -232,10 +232,20 @@ export function fromResponse(response) {
232
232
  const usage = response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined;
233
233
  return { stop_reason, content, ...(usage ? { usage } : {}) };
234
234
  }
235
+ /** OpenAI's reasoning models (GPT-5 on, the o-series), which reason by
236
+ * default and take a reasoning effort; older ones (gpt-4o, gpt-4.1)
237
+ * reject one. */
238
+ export function isOpenAIReasoningModel(model) {
239
+ return /^(gpt-(5|6|7|8|9)|o\d)/i.test(model);
240
+ }
235
241
  export function createOpenAIModelCall(options) {
236
242
  const client = options.client ?? new OpenAI({ apiKey: options.apiKey });
243
+ // A reasoning model with no effort set reasons at OpenAI's default
244
+ // anyway (medium), which used to spend the 4096-token cap before it
245
+ // got to a tool call. Unset is low for those: quick, and room to act.
246
+ const reasoningEffort = options.reasoningEffort ?? (isOpenAIReasoningModel(options.model) ? 'low' : undefined);
237
247
  // Reasoning counts toward max_output_tokens.
238
- const maxTokens = options.maxTokens ?? (options.reasoningEffort && options.reasoningEffort !== 'none' ? 16000 : 4096);
248
+ const maxTokens = options.maxTokens ?? (reasoningEffort && reasoningEffort !== 'none' ? 16000 : 4096);
239
249
  return async (messages, system, tools) => {
240
250
  const response = await client.responses.create({
241
251
  model: options.model,
@@ -245,7 +255,7 @@ export function createOpenAIModelCall(options) {
245
255
  tools: tools.length
246
256
  ? tools.map((t) => ({ type: 'function', name: t.name, description: t.description, parameters: t.input_schema, strict: false }))
247
257
  : undefined,
248
- ...(options.reasoningEffort ? { reasoning: { effort: options.reasoningEffort } } : {}),
258
+ ...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}),
249
259
  store: false,
250
260
  include: ['reasoning.encrypted_content'],
251
261
  });
@@ -411,7 +411,8 @@ export function workflowTemplate(plan) {
411
411
  ...(plan.apps?.length ? { apps: plan.apps } : {}),
412
412
  keys: plan.keys,
413
413
  skills: [{ id: plan.id, description: plan.skill.description, body: plan.skill.body }],
414
- rules: plan.rules.map((r) => ({ name: slugify(`${plan.id}-${r.tool}`, `${plan.id}-rule`), tool: r.tool, decision: r.decision })),
414
+ // Named after the tool: readable, and made unique if taken.
415
+ rules: plan.rules.map((r) => ({ name: r.tool, tool: r.tool, decision: r.decision })),
415
416
  ...(plan.trigger.kind === 'schedule' && plan.trigger.every
416
417
  ? { schedule: { every: plan.trigger.every, ...(plan.trigger.at ? { at: plan.trigger.at } : {}), ...(plan.trigger.weekday !== undefined ? { weekday: plan.trigger.weekday } : {}), name: plan.name, message: plan.startMessage, workflowId: plan.id, ...(plan.notify?.on ? { notify: true } : {}) } }
417
418
  : {}),
@@ -23,7 +23,38 @@ export declare function readActauthConfig(agentName: string): ActauthConfigView;
23
23
  /** Appends a new rule. `rule.name` must be unique among this agent's
24
24
  * existing rules — it's the only handle update/removeActauthRule below
25
25
  * have to find it again, so a duplicate would make those ambiguous. */
26
- export declare function addActauthRule(agentName: string, rule: ActauthRuleInput): void;
26
+ /** Adds a rule at the end, or, with `before`, just above that named rule
27
+ * (the first rule that matches a call decides it, so above is what makes
28
+ * it count). */
29
+ export declare function addActauthRule(agentName: string, rule: ActauthRuleInput, options?: {
30
+ before?: string;
31
+ }): void;
32
+ type RuleDecision = 'allow' | 'ask' | 'deny';
33
+ export interface PlannedRule {
34
+ tool: string;
35
+ decision: RuleDecision;
36
+ /** Its name in actauth.yml; the tool's name (made unique) by default. */
37
+ name?: string;
38
+ }
39
+ export interface PlannedRulesResult {
40
+ added: string[];
41
+ /** Tools that already had a rule with the same decision. */
42
+ unchanged: string[];
43
+ /** Tools whose first rule is stricter or looser than planned and was
44
+ * kept as it is: the plan never loosens a decision the agent has. */
45
+ kept: {
46
+ tool: string;
47
+ has: RuleDecision;
48
+ wants: RuleDecision;
49
+ }[];
50
+ }
51
+ /** Gives the planned tools their decisions, once the agent's abilities and
52
+ * apps are installed (their own rules in place), so nothing is added twice:
53
+ * a tool with no rule gets one; one whose first matching rule already says
54
+ * the same is left alone; one a plan wants stricter (ask where an ability
55
+ * allows) gets its rule just above, so it counts; one a plan wants looser
56
+ * keeps what it has, and is reported. */
57
+ export declare function applyPlannedRules(agentName: string, planned: PlannedRule[]): PlannedRulesResult;
27
58
  /** Mutates an existing rule's scope/tool/decision in place (via the
28
59
  * node's own `.set()`, not remove-then-re-add), so any comment attached
29
60
  * to that specific rule in the source YAML survives the edit. */
@@ -34,3 +65,4 @@ export declare function updateActauthRule(agentName: string, ruleName: string, u
34
65
  }): void;
35
66
  export declare function removeActauthRule(agentName: string, ruleName: string): void;
36
67
  export declare function setDefaultDecision(agentName: string, decision: Decision): void;
68
+ export {};
@@ -62,16 +62,53 @@ function findRuleIndex(rules, ruleName) {
62
62
  /** Appends a new rule. `rule.name` must be unique among this agent's
63
63
  * existing rules — it's the only handle update/removeActauthRule below
64
64
  * have to find it again, so a duplicate would make those ambiguous. */
65
- export function addActauthRule(agentName, rule) {
65
+ /** Adds a rule at the end, or, with `before`, just above that named rule
66
+ * (the first rule that matches a call decides it, so above is what makes
67
+ * it count). */
68
+ export function addActauthRule(agentName, rule, options = {}) {
66
69
  const dir = agentDir(agentName);
67
70
  const { path, doc } = loadDoc(dir);
68
71
  const rules = doc.get('rules');
69
72
  if (findRuleIndex(rules, rule.name) !== -1) {
70
73
  throw new ActauthRuleExistsError(`A rule named '${rule.name}' already exists for '${agentName}' — pick a different name or remove it first.`);
71
74
  }
72
- rules.add(doc.createNode({ name: rule.name, scope: rule.scope, tool: rule.tool, decision: rule.decision }));
75
+ const node = doc.createNode({ name: rule.name, scope: rule.scope, tool: rule.tool, decision: rule.decision });
76
+ const at = options.before ? findRuleIndex(rules, options.before) : -1;
77
+ if (at === -1)
78
+ rules.add(node);
79
+ else
80
+ rules.items.splice(at, 0, node);
73
81
  saveDoc(dir, path, doc);
74
82
  }
83
+ const STRICTNESS = { allow: 0, ask: 1, deny: 2 };
84
+ /** Gives the planned tools their decisions, once the agent's abilities and
85
+ * apps are installed (their own rules in place), so nothing is added twice:
86
+ * a tool with no rule gets one; one whose first matching rule already says
87
+ * the same is left alone; one a plan wants stricter (ask where an ability
88
+ * allows) gets its rule just above, so it counts; one a plan wants looser
89
+ * keeps what it has, and is reported. */
90
+ export function applyPlannedRules(agentName, planned) {
91
+ const result = { added: [], unchanged: [], kept: [] };
92
+ for (const rule of planned) {
93
+ const current = readActauthConfig(agentName).rules;
94
+ const first = current.find((r) => r.tool === rule.tool);
95
+ if (first && first.decision === rule.decision) {
96
+ result.unchanged.push(rule.tool);
97
+ continue;
98
+ }
99
+ if (first && STRICTNESS[rule.decision] < STRICTNESS[first.decision]) {
100
+ result.kept.push({ tool: rule.tool, has: first.decision, wants: rule.decision });
101
+ continue;
102
+ }
103
+ const base = rule.name ?? rule.tool;
104
+ let name = base;
105
+ for (let i = 2; current.some((r) => r.name === name); i++)
106
+ name = `${base}-${i}`;
107
+ addActauthRule(agentName, { name, scope: '*/*', tool: rule.tool, decision: rule.decision }, first ? { before: first.name } : {});
108
+ result.added.push(name);
109
+ }
110
+ return result;
111
+ }
75
112
  /** Mutates an existing rule's scope/tool/decision in place (via the
76
113
  * node's own `.set()`, not remove-then-re-add), so any comment attached
77
114
  * to that specific rule in the source YAML survives the edit. */
@@ -438,10 +438,6 @@ ${consoleShellScript}
438
438
  return attempt().catch(function () { return attempt(); });
439
439
  }
440
440
 
441
- function badge(label) {
442
- return '<span class="badge ' + (label === 'custom' ? 'badge-custom' : 'badge-default') + '">' + escapeHtml(label) + '</span>';
443
- }
444
-
445
441
  // A small, deliberately non-CommonMark markdown-to-HTML renderer for
446
442
  // the skill Body field's Preview toggle (see wireSkillsHandlers) — this
447
443
  // page stays a self-contained string, no build step, no CDN (same
@@ -899,6 +895,7 @@ ${consoleShellScript}
899
895
  if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
900
896
  var workflows = r.body.workflows || [];
901
897
  fillWorkflowsCard(workflows);
898
+ leShell.setCount('workflows', workflows.length);
902
899
  workflowsById = {};
903
900
  workflows.forEach(function (w) { workflowsById[w.id] = w; });
904
901
  el.innerHTML = renderWorkflowsHtml(name, workflows);
@@ -1224,7 +1221,11 @@ ${consoleShellScript}
1224
1221
  var action = btn.dataset.action;
1225
1222
  if (action === 'cancel' && btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
1226
1223
  btn.disabled = true;
1227
- fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () { loadWorkflowsSection(name); });
1224
+ fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () {
1225
+ loadWorkflowsSection(name);
1226
+ // The Schedules tab's count, and the Overview's tile.
1227
+ fillOverviewCards(name);
1228
+ });
1228
1229
  });
1229
1230
  }
1230
1231
  card.querySelector('.workflow-run').addEventListener('click', function (ev) {
@@ -1277,6 +1278,7 @@ ${consoleShellScript}
1277
1278
  var waiting = active.filter(function (t) { return t.status === 'awaiting_approval'; }).length;
1278
1279
  var next = active.map(function (t) { return t.next_run_at; }).filter(Boolean).sort()[0];
1279
1280
  set('ovSchedulesValue', String(active.length));
1281
+ leShell.setCount('schedules', active.length);
1280
1282
  var schedulesDetail = [];
1281
1283
  if (waiting) schedulesDetail.push(waiting + ' waiting for approval');
1282
1284
  if (next) schedulesDetail.push('Next run ' + escapeHtml(new Date(next).toLocaleString()));
@@ -1310,7 +1312,10 @@ ${consoleShellScript}
1310
1312
  '<dt>Provider</dt><dd>' + escapeHtml(cfg.model.provider) + '</dd>' +
1311
1313
  '<dt>Model</dt><dd>' + escapeHtml(cfg.model.model || '(provider default)') + '</dd>' +
1312
1314
  '<dt>Max tokens</dt><dd>' + escapeHtml(cfg.model.maxTokens != null ? cfg.model.maxTokens : '(default)') + '</dd>' +
1313
- '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort || (REASONING_PROVIDERS.indexOf(cfg.model.provider) !== -1 ? '(model default)' : 'n/a')) + '</dd>' +
1315
+ // Unset is low for OpenAI's reasoning models (core/model-calls/openai-model-call.ts).
1316
+ '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort ||
1317
+ (cfg.model.provider === 'openai' && /^(gpt-[5-9]|o[0-9])/i.test(cfg.model.model || '') ? 'low (default)'
1318
+ : REASONING_PROVIDERS.indexOf(cfg.model.provider) !== -1 ? '(model default)' : 'n/a')) + '</dd>' +
1314
1319
  '</dl>' +
1315
1320
  '<form class="add-source" id="modelForm" style="display:none">' +
1316
1321
  '<label>Provider' +
@@ -1326,7 +1331,7 @@ ${consoleShellScript}
1326
1331
  '<label>Max tokens <span class="hint">(blank for provider default)</span>' +
1327
1332
  '<input type="number" name="maxTokens" min="1" step="1" value="' + (cfg.model.maxTokens != null ? escapeHtml(cfg.model.maxTokens) : '') + '">' +
1328
1333
  '</label>' +
1329
- '<label id="reasoningEffortField">Reasoning effort <span class="hint">(how much the model thinks before answering: none turns it off, unset uses the model\\\'s default)</span>' +
1334
+ '<label id="reasoningEffortField">Reasoning effort <span class="hint">(how much the model thinks before answering: none turns it off, unset uses the model\\\'s default, and low for OpenAI\\\'s reasoning models)</span>' +
1330
1335
  '<select name="reasoningEffort">' +
1331
1336
  ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].map(function (v) {
1332
1337
  return '<option value="' + v + '"' + (v === (cfg.model.reasoningEffort || '') ? ' selected' : '') + '>' + (v || '(unset)') + '</option>';
@@ -1342,11 +1347,6 @@ ${consoleShellScript}
1342
1347
  return renderOverviewCards(cfg) +
1343
1348
  '<section>' + systemPromptSection + '</section>' +
1344
1349
  '<section>' + modelSection + '</section>' +
1345
- '<section><h3>Hooks</h3><dl class="kv">' +
1346
- '<dt>sessionIdFor</dt><dd>' + badge(cfg.sessionIdFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.sessionIdFor) + '</span></dd>' +
1347
- '<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
1348
- '<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
1349
- '</dl></section>' +
1350
1350
  '<section><h3>Limits &amp; budgets <button type="button" class="edit-btn" id="editLimitsBtn">Edit</button></h3>' +
1351
1351
  '<dl class="kv" id="limitsDisplay">' +
1352
1352
  '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
@@ -1367,7 +1367,6 @@ ${consoleShellScript}
1367
1367
  '<button type="button" id="cancelLimitsBtn">Cancel</button>' +
1368
1368
  '<div class="error" id="limitsError"></div>' +
1369
1369
  '</form>' +
1370
- '<dl class="kv"><dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd></dl>' +
1371
1370
  '</section>';
1372
1371
  }
1373
1372
 
@@ -1956,7 +1955,10 @@ ${consoleShellScript}
1956
1955
  (saved ? '<button type="button" class="delete-btn notifier-remove"' + (locked ? ' disabled' : '') + '>Remove</button>' : '') + '</div>' +
1957
1956
  (isWebhook ? '<p class="hint" style="margin:0">A webhook always sends once saved, alongside the primary messaging app.</p>' : '') +
1958
1957
  '<div class="hint notifier-result"></div>' +
1959
- '</form></details>';
1958
+ '</form>' +
1959
+ // Who can decide and give jobs here: part of this app's settings.
1960
+ (channel === 'slack' || channel === 'telegram' ? renderApproversHtml(n, channel) : '') +
1961
+ '</details>';
1960
1962
  }
1961
1963
 
1962
1964
  function renderConnectionsHtml(n) {
@@ -2011,7 +2013,7 @@ ${consoleShellScript}
2011
2013
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
2012
2014
  }).join('');
2013
2015
  var where = app === 'telegram' ? 'by replying to it' : 'by replying in its thread';
2014
- return '<section class="approvers-section" data-app="' + app + '"><h3>' + title + ' approvers</h3>' +
2016
+ return '<section class="approvers-section" data-app="' + app + '" style="max-width:560px;margin-top:18px;padding-top:14px;border-top:1px solid var(--le-line, #e5e7eb)"><h4 style="margin:0 0 6px">Approvers</h4>' +
2015
2017
  (n.primary !== app ? '<p class="hint" style="margin:0 0 8px">' + title + ' isn&#39;t primary, so requests aren&#39;t sent there until it is. Jobs started there still use this list.</p>' : '') +
2016
2018
  '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request ' + where + ', like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A plain &ldquo;yes&rdquo; runs it; a reply that&#39;s less clear (&ldquo;sure, send it&rdquo;, &ldquo;yes but check the amount&rdquo;) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count, and only approvers can give the agent jobs. Anyone can still ask about a request.</p>' +
2017
2019
  (approvers.length
@@ -2101,8 +2103,7 @@ ${consoleShellScript}
2101
2103
  '<p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
2102
2104
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
2103
2105
  (n.source === 'code' ? '' : renderConnectionsHtml(n)) + '</section>' +
2104
- renderApproversHtml(n, 'slack') +
2105
- renderApproversHtml(n, 'telegram') +
2106
+
2106
2107
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
2107
2108
  }
2108
2109
 
@@ -2251,8 +2252,16 @@ ${consoleShellScript}
2251
2252
  fetchJsonRetryOnce(url).then(function (r) {
2252
2253
  if (!r.ok) { out.textContent = r.body.error || 'Could not check'; return; }
2253
2254
  var b = r.body;
2255
+ // Telegram keeps its last error until a newer one, so it says when.
2256
+ var when = '';
2257
+ if (b.lastErrorAt) {
2258
+ var mins = Math.max(0, Math.round((Date.now() - Date.parse(b.lastErrorAt)) / 60000));
2259
+ when = mins < 2 ? 'just now' : mins < 120 ? mins + ' minutes ago' : mins < 2880 ? Math.round(mins / 60) + ' hours ago' : Math.round(mins / 1440) + ' days ago';
2260
+ when = ' (' + when + ', ' + new Date(b.lastErrorAt).toLocaleString() + ')';
2261
+ }
2254
2262
  out.textContent = (b.pointsHere ? 'Connected' + (b.botUsername ? ' (@' + b.botUsername + ')' : '') + '.' : b.url ? 'The bot sends its updates somewhere else: ' + b.url + '. Connect again to point it here.' : 'The bot isn' + String.fromCharCode(8217) + 't sending its updates anywhere. Connect again.') +
2255
- (b.lastError ? ' Telegram' + String.fromCharCode(8217) + 's last delivery error: ' + b.lastError : '');
2263
+ (b.pending ? ' ' + b.pending + ' update' + (b.pending === 1 ? '' : 's') + ' waiting to be delivered.' : '') +
2264
+ (b.lastError ? ' Telegram' + String.fromCharCode(8217) + 's last delivery error' + when + ': ' + b.lastError + (b.pointsHere && !b.pending ? ' Nothing is waiting, so it' + String.fromCharCode(8217) + 's likely from before and since fixed.' : '') : '');
2256
2265
  });
2257
2266
  });
2258
2267
  }
@@ -3610,11 +3619,7 @@ ${consoleShellScript}
3610
3619
 
3611
3620
  function renderSkillsTabHtml(cfg) {
3612
3621
  var listHtml = cfg.skills.length ? cfg.skills.map(renderSkillCard).join('') : '<p class="hint">No skills yet.</p>';
3613
- return '<section><h3>Skills dirs</h3><dl class="kv">' +
3614
- '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
3615
- '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
3616
- '</dl></section>' +
3617
- '<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
3622
+ return '<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
3618
3623
  '<section><h3 id="skillFormHeading">Add a skill</h3>' +
3619
3624
  '<form class="add-source" id="skillForm">' +
3620
3625
  '<label>Id <span class="hint">(lowercase, hyphen-separated — becomes the SKILL.md folder name; nested skills aren\\'t editable here)</span>' +
@@ -4501,7 +4506,7 @@ ${consoleShellScript}
4501
4506
  prompt: cfg.systemPrompt,
4502
4507
  chips: [modelChip(cfg), { text: 'unmatched calls: ' + cfg.permissions.defaultDecision }],
4503
4508
  tab: currentTab,
4504
- counts: { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, actauth: cfg.permissions.rules.length },
4509
+ counts: { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, workflows: cfg.skills.filter(function (s) { return s.workflow; }).length },
4505
4510
  }) +
4506
4511
  '<div class="le-scroll"><div class="le-page" style="display:block">' +
4507
4512
  '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
@@ -56,8 +56,8 @@ export const consoleShellScript = `<script>
56
56
  // ability on the Apps tab (id 'abilities', so older links still work).
57
57
  // ?tab=tools and ?tab=skills still open Advanced.
58
58
  var TABS = [
59
- ['overview', 'Overview'], ['workflows', 'Workflows'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
60
- ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment'], ['advanced', 'Advanced'], ['chat', 'Chat']
59
+ ['overview', 'Overview'], ['workflows', 'Workflows'], ['abilities', 'Apps'], ['chat', 'Chat'],
60
+ ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['actauth', 'Permissions'], ['env', 'Environment'], ['advanced', 'Advanced']
61
61
  ];
62
62
 
63
63
  function esc(s) {
@@ -127,6 +127,8 @@ export declare function setupAgentTelegramWebhook(config: AgentConfig, baseUrl:
127
127
  export declare function agentTelegramWebhookInfo(config: AgentConfig): Promise<{
128
128
  url?: string;
129
129
  lastError?: string;
130
+ lastErrorAt?: string;
131
+ pending?: number;
130
132
  pointsHere: boolean;
131
133
  botUsername?: string;
132
134
  }>;
@@ -161,7 +161,7 @@ export function jobAllowed(config, app, message) {
161
161
  const home = app === 'slack' ? file?.channelId : file?.chatId;
162
162
  if (!message.direct && home && home === message.conversation)
163
163
  return { allowed: true };
164
- return { allowed: false, message: `Jobs by direct message need approvers: add yourself in this agent's Messaging tab in LoopEngine (${APP_NAMES[app]} section).` };
164
+ return { allowed: false, message: `Jobs by direct message need approvers: in LoopEngine, open this agent's Messaging tab, then ${APP_NAMES[app]} → Approvers, and add yourself.` };
165
165
  }
166
166
  /** Replaces the approver list on the agent's saved connection for `app`,
167
167
  * primary or not. Throws NotifierFileError when there's no connection
@@ -224,6 +224,8 @@ export async function agentTelegramWebhookInfo(config) {
224
224
  return {
225
225
  ...(info.url ? { url: info.url } : {}),
226
226
  ...(info.lastError ? { lastError: info.lastError } : {}),
227
+ ...(info.lastErrorAt ? { lastErrorAt: info.lastErrorAt } : {}),
228
+ ...(info.pending ? { pending: info.pending } : {}),
227
229
  pointsHere: Boolean(info.url?.endsWith(`/hooks/telegram/${encodeURIComponent(config.name)}`)),
228
230
  ...(botUsername ? { botUsername } : {}),
229
231
  };
@@ -1433,7 +1433,7 @@ ${consoleShellScript}
1433
1433
  var counts = {};
1434
1434
  if (cfg) {
1435
1435
  chips.push({ text: typeof cfg.model === 'string' ? cfg.model : cfg.model.provider + ' \u00b7 ' + (cfg.model.model || 'default model'), mono: true });
1436
- counts = { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, actauth: cfg.permissions.rules.length };
1436
+ counts = { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount };
1437
1437
  }
1438
1438
  head.innerHTML = leShell.renderHead({ name: name, prompt: agent.systemPrompt, chips: chips, tab: 'chat', counts: counts });
1439
1439
  }
@@ -11,7 +11,7 @@
11
11
  import { createScheduledTask, pauseScheduledTask } from '../core/scheduler.js';
12
12
  import { workspaceTimeZone } from '../core/time-zone.js';
13
13
  import { STARTER_TEMPLATES, starterTemplate, templateScheduleStart } from '../core/system-agents/templates.js';
14
- import { ActauthRuleExistsError, addActauthRule, setDefaultDecision } from './actauth-admin.js';
14
+ import { ActauthRuleExistsError, addActauthRule, applyPlannedRules, setDefaultDecision } from './actauth-admin.js';
15
15
  import { setAgentEnvVar } from './env-admin.js';
16
16
  import { freeAgentName } from './interview-admin.js';
17
17
  import { writeSkill } from './skills-admin.js';
@@ -126,19 +126,9 @@ export async function createFromTemplate(id, deps) {
126
126
  export async function createFromTemplateObject(template, deps) {
127
127
  const { candidate, setup } = template;
128
128
  const name = freeAgentName(candidate.agent.name, deps.agentExists);
129
- // 1. The agent, and its permissions: everything asks unless a rule says
130
- // otherwise, and the template's rules come first.
129
+ // 1. The agent: everything asks unless a rule says otherwise.
131
130
  await deps.scaffoldAgent(name, candidate.agent.systemPrompt);
132
131
  setDefaultDecision(name, 'ask');
133
- for (const rule of setup.rules) {
134
- try {
135
- addActauthRule(name, { name: rule.name, scope: '*/*', tool: rule.tool, decision: rule.decision });
136
- }
137
- catch (err) {
138
- if (!(err instanceof ActauthRuleExistsError))
139
- throw err;
140
- }
141
- }
142
132
  // 2. Abilities: their tools, skills and rules. One failing doesn't stop
143
133
  // the rest; it heads the checklist.
144
134
  const installed = [];
@@ -185,6 +175,9 @@ export async function createFromTemplateObject(template, deps) {
185
175
  failed.push({ name: app.title, error: installFailureReason(err) });
186
176
  }
187
177
  }
178
+ // The template's rules, now that abilities and apps have added theirs:
179
+ // none twice, a stricter one placed so it counts, none loosened.
180
+ applyPlannedRules(name, setup.rules.map((r) => ({ name: r.name, tool: r.tool, decision: r.decision })));
188
181
  // 3. Its own skills and non-secret settings.
189
182
  for (const skill of setup.skills ?? [])
190
183
  writeSkill(name, skill.id, { description: skill.description, body: skill.body });
@@ -6,11 +6,11 @@
6
6
  // one without changing anything that agent already does.
7
7
  import { join } from 'node:path';
8
8
  import { addGatewayTool, readGatewayToolsFromDir } from '../core/gateway-tools.js';
9
- import { addActauthRule, readActauthConfig } from './actauth-admin.js';
9
+ import { applyPlannedRules } from './actauth-admin.js';
10
10
  import { writeSkill } from './skills-admin.js';
11
11
  import { createFromTemplateObject, installFailureReason, templateChecklist } from './templates-admin.js';
12
12
  import { templateScheduleStart } from '../core/system-agents/templates.js';
13
- import { planParameters, planWebhook, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
13
+ import { planParameters, planWebhook, planWorkflowWithModel, workflowTemplate } from '../core/system-agents/workflow-planner.js';
14
14
  import { createTrigger, getTriggerStore, listTriggers, publicTrigger } from '../core/workflow-triggers.js';
15
15
  import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
16
16
  import { clearWorkflowFields, migrateWorkflowsFile, readWorkflow, readWorkflows, setWorkflowFields, validateParams, workflowRunMessage } from '../core/workflows.js';
@@ -375,18 +375,7 @@ export async function createWorkflow(input, deps) {
375
375
  if (!agent)
376
376
  throw new WorkflowError(`There's no agent named ${target.agent ?? '(none)'}.`);
377
377
  const check = checkWorkflowForAgent(plan, agent);
378
- // 1. Rules: only for tools the agent has no rule for.
379
- const { rules: current } = readActauthConfig(agent.name);
380
- for (const rule of plan.rules) {
381
- if (current.some((r) => r.tool === rule.tool))
382
- continue;
383
- let name = slugify(`${plan.id}-${rule.tool}`, `${plan.id}-rule`);
384
- for (let i = 2; current.some((r) => r.name === name); i++)
385
- name = `${slugify(`${plan.id}-${rule.tool}`)}-${i}`;
386
- addActauthRule(agent.name, { name, scope: '*/*', tool: rule.tool, decision: rule.decision });
387
- current.push({ name, scope: '*/*', tool: rule.tool, decision: rule.decision });
388
- }
389
- // 2. Abilities it doesn't have yet.
378
+ // 1. Abilities it doesn't have yet (their rules come with them).
390
379
  const installed = [];
391
380
  const failed = [...mcp.failed];
392
381
  for (const spec of check.toInstall) {
@@ -441,6 +430,10 @@ export async function createWorkflow(input, deps) {
441
430
  const connected = copyConnectedTools(plan, agent.name, agents, deps.agentDir);
442
431
  installed.push(...connected.copied);
443
432
  failed.push(...connected.failed);
433
+ // 2. Rules, now that what was installed has added its own: none twice,
434
+ // a stricter one (ask where an ability allows) placed so it counts,
435
+ // and a decision the agent already has never loosened.
436
+ const ruled = applyPlannedRules(agent.name, plan.rules);
444
437
  // 3. Its steps, as a skill; then load the agent again with all of it.
445
438
  writeSkill(agent.name, plan.id, { description: plan.skill.description, body: plan.skill.body });
446
439
  let loadError;
@@ -477,7 +470,7 @@ export async function createWorkflow(input, deps) {
477
470
  newAgent: false,
478
471
  workflow: plan.name,
479
472
  abilities: { installed, failed },
480
- keptDecisions: check.conflicts,
473
+ keptDecisions: ruled.kept,
481
474
  checklist: [...withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed, appsToConnect)), ...hook.checklist],
482
475
  ...(hook.webhook ? { webhook: hook.webhook } : {}),
483
476
  ...(hook.webhookError ? { webhookError: hook.webhookError } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",