loopengine 0.1.95 → 0.1.97

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.
Files changed (68) hide show
  1. package/README.md +21 -2
  2. package/dist/bin/ability-manager.d.ts +4 -0
  3. package/dist/bin/ability-manager.js +34 -2
  4. package/dist/bin/cli.d.ts +1 -1
  5. package/dist/bin/cli.js +3 -0
  6. package/dist/core/ability-details.d.ts +31 -0
  7. package/dist/core/ability-details.js +140 -0
  8. package/dist/core/ability-sources.d.ts +59 -0
  9. package/dist/core/ability-sources.js +312 -0
  10. package/dist/core/agent-config.d.ts +21 -1
  11. package/dist/core/discover-agents.js +11 -1
  12. package/dist/core/http-notifier.d.ts +7 -1
  13. package/dist/core/http-notifier.js +68 -9
  14. package/dist/core/http-notify-triggers/email.d.ts +2 -2
  15. package/dist/core/http-notify-triggers/email.js +2 -4
  16. package/dist/core/http-notify-triggers/lark.d.ts +2 -2
  17. package/dist/core/http-notify-triggers/lark.js +2 -4
  18. package/dist/core/http-notify-triggers/slack.d.ts +2 -2
  19. package/dist/core/http-notify-triggers/slack.js +2 -4
  20. package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
  21. package/dist/core/http-notify-triggers/telegram.js +71 -0
  22. package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
  23. package/dist/core/http-notify-triggers/webhook.js +2 -4
  24. package/dist/core/model-calls/meta-model-call.d.ts +15 -0
  25. package/dist/core/model-calls/meta-model-call.js +48 -0
  26. package/dist/core/model-calls/openai-model-call.d.ts +5 -0
  27. package/dist/core/model-calls/openai-model-call.js +47 -1
  28. package/dist/core/model-calls/xai-model-call.d.ts +15 -0
  29. package/dist/core/model-calls/xai-model-call.js +45 -0
  30. package/dist/core/notifier-file.d.ts +40 -6
  31. package/dist/core/notifier-file.js +119 -25
  32. package/dist/core/system-agents/workflow-planner.d.ts +100 -0
  33. package/dist/core/system-agents/workflow-planner.js +278 -0
  34. package/dist/core/workflows.d.ts +26 -0
  35. package/dist/core/workflows.js +39 -0
  36. package/dist/core/workspace-abilities.d.ts +31 -0
  37. package/dist/core/workspace-abilities.js +110 -0
  38. package/dist/index.d.ts +18 -4
  39. package/dist/index.js +17 -3
  40. package/dist/web/ability-details-admin.d.ts +17 -0
  41. package/dist/web/ability-details-admin.js +45 -0
  42. package/dist/web/ability-sources-admin.d.ts +33 -0
  43. package/dist/web/ability-sources-admin.js +65 -0
  44. package/dist/web/agent-file-admin.d.ts +1 -1
  45. package/dist/web/agent-file-admin.js +3 -0
  46. package/dist/web/agents-config-page.js +600 -220
  47. package/dist/web/agents-list-page.js +49 -20
  48. package/dist/web/console-shell.js +244 -3
  49. package/dist/web/dev-ui-styles.js +12 -0
  50. package/dist/web/global-config-page.js +348 -95
  51. package/dist/web/global-config.d.ts +44 -1
  52. package/dist/web/global-config.js +162 -2
  53. package/dist/web/interview-admin.js +2 -2
  54. package/dist/web/messaging-icons.d.ts +2 -0
  55. package/dist/web/messaging-icons.js +7 -0
  56. package/dist/web/notifier-admin.d.ts +33 -15
  57. package/dist/web/notifier-admin.js +109 -65
  58. package/dist/web/provider-icons.d.ts +2 -0
  59. package/dist/web/provider-icons.js +10 -0
  60. package/dist/web/templates-admin.d.ts +3 -0
  61. package/dist/web/templates-admin.js +5 -1
  62. package/dist/web/workflow-admin.d.ts +124 -0
  63. package/dist/web/workflow-admin.js +308 -0
  64. package/dist/web/workflow-page.d.ts +1 -0
  65. package/dist/web/workflow-page.js +226 -0
  66. package/dist/web/workspace-abilities-admin.d.ts +24 -0
  67. package/dist/web/workspace-abilities-admin.js +118 -0
  68. package/package.json +13 -1
@@ -26,6 +26,7 @@
26
26
  // concatenation, not a nested template literal, for the same
27
27
  // backtick/${}-escaping reason playground.ts's own header comment explains.
28
28
  import { devUiCss } from './dev-ui-styles.js';
29
+ import { MESSAGING_ICONS } from './messaging-icons.js';
29
30
  import { consoleShellScript, consoleSidebarHtml } from './console-shell.js';
30
31
  export const agentsConfigPageHtml = `<!doctype html>
31
32
  <html lang="en">
@@ -46,7 +47,34 @@ export const agentsConfigPageHtml = `<!doctype html>
46
47
  form.add-source .schedule-when input[type="radio"] { width: auto; margin: 0; }
47
48
  form.add-source .schedule-when input[type="number"] { width: 80px; }
48
49
  form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
50
+ .workflow-card { margin-bottom: 10px; }
51
+ .workflow-steps { margin: 0; padding-left: 20px; font-size: 13px; }
52
+ .workflow-steps li { margin: 3px 0; }
53
+ .advanced-part { font-size: 17px; font-weight: 600; margin: 28px 0 12px; padding-top: 16px; border-top: 1px solid var(--line); }
54
+ .advanced-part#advanced-tools { margin-top: 0; padding-top: 0; border-top: 0; }
49
55
  .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; }
56
+ /* Permissions: Allow / Ask me / Never, switched in place and saved at once. */
57
+ .decision-switch { display: inline-flex; gap: 4px; flex-wrap: nowrap; }
58
+ .actauth-rules td:nth-child(3) { white-space: nowrap; width: 1%; }
59
+ .actauth-rules-full td:nth-child(3) { white-space: normal; width: auto; }
60
+ .actauth-rules-full td:nth-child(5), .actauth-rules-full td:nth-child(6) { white-space: nowrap; width: 1%; }
61
+ .actauth-rules td code { overflow-wrap: anywhere; }
62
+ .decision-switch button { min-width: 64px; padding: 5px 10px; white-space: nowrap; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); font-size: 12.5px; font-weight: 600; cursor: pointer; }
63
+ .decision-switch button:hover { border-color: var(--ink-faint); }
64
+ .decision-switch button[aria-pressed="true"] { color: var(--on-accent); }
65
+ .decision-switch button[aria-pressed="true"][data-decision="allow"] { background: var(--accent); border-color: var(--accent); }
66
+ .decision-switch button[aria-pressed="true"][data-decision="ask"] { background: var(--warn); border-color: var(--warn); }
67
+ .decision-switch button[aria-pressed="true"][data-decision="deny"] { background: var(--ink-muted); border-color: var(--ink-muted); }
68
+ .decision-switch.is-saving button { cursor: progress; opacity: 0.7; }
69
+ /* Messaging: one section per app; the primary one is outlined. */
70
+ .msg-conns { display: grid; gap: 10px; max-width: 720px; }
71
+ .msg-conn { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 0 14px; }
72
+ .msg-conn.is-primary { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
73
+ .msg-conn > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 0; cursor: pointer; }
74
+ .msg-conn-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 8px; }
75
+ .le-channel-icon { width: 18px; height: 18px; flex: none; }
76
+ .msg-conn-blurb { flex-basis: 100%; margin: 0; }
77
+ .msg-conn > form.add-source { border: 0; background: none; box-shadow: none; padding: 0 0 14px; margin: 0; }
50
78
  .env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
51
79
  form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
52
80
  form.add-source.env-inline input, form.add-source.env-inline select { flex: 1 1 140px; width: auto; min-width: 0; }
@@ -626,6 +654,76 @@ ${consoleShellScript}
626
654
  // Messaging and Schedules come from their own endpoints, so their
627
655
  // cards are filled in once those answer. Called after every render of
628
656
  // Overview, which is also re-rendered after edits.
657
+ // ---- Overview: the agent's workflows (web/workflow-admin.ts), added
658
+ // from "Describe a workflow": each with its trigger, its schedule's
659
+ // state, its steps (checkpoints marked) and Run now. ----
660
+
661
+ function renderWorkflowsHtml(name, workflows) {
662
+ if (!workflows.length) {
663
+ return '<p class="hint" style="margin:0">No workflows yet. <a href="/workflows/new">Describe a workflow</a>: say the steps and where you want to decide, and it' + String.fromCharCode(8217) + 's set up for you.</p>';
664
+ }
665
+ return workflows.map(function (w) {
666
+ var s = w.schedule;
667
+ var state = !s
668
+ ? (w.trigger.kind === 'schedule' ? '<span class="hint">no schedule found</span>' : '<span class="hint">started when asked</span>')
669
+ : s.waiting
670
+ ? '<span class="le-pill ask">Waiting for a decision</span>'
671
+ : s.status === 'paused'
672
+ ? '<span class="le-pill">Schedule paused</span>'
673
+ : s.status === 'running'
674
+ ? '<span class="le-pill accent">Running</span>'
675
+ : '<span class="le-pill good">On</span>' + (s.nextRunAt ? ' <span class="hint">next ' + escapeHtml(new Date(s.nextRunAt).toLocaleString()) + '</span>' : '');
676
+ var steps = (w.steps || []).map(function (st) {
677
+ var mark = st.kind === 'ask' ? ' <span class="le-pill accent">Asks you</span>' : st.kind === 'approval' ? ' <span class="le-pill ask">You approve</span>' : '';
678
+ return '<li>' + escapeHtml(st.title) + (st.tool && st.tool !== 'system_ask_user' ? ' <code>' + escapeHtml(st.tool) + '</code>' : '') + mark + '</li>';
679
+ }).join('');
680
+ return '<div class="source workflow-card" data-workflow="' + escapeHtml(w.id) + '">' +
681
+ '<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:baseline">' +
682
+ '<strong>' + escapeHtml(w.name) + '</strong><span>' + state + '</span></div>' +
683
+ '<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.summary) + ' ' + String.fromCharCode(183) + ' ' + escapeHtml(w.trigger.description) + '</p>' +
684
+ '<ol class="workflow-steps">' + steps + '</ol>' +
685
+ '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">' +
686
+ '<button type="button" class="workflow-run">Run now</button>' +
687
+ (s ? '<a href="?tab=schedules" data-tab="schedules" class="hint" style="align-self:center">Schedule</a>' : '') +
688
+ '<button type="button" class="delete-btn workflow-forget">Forget</button>' +
689
+ '</div><div class="hint workflow-result"></div></div>';
690
+ }).join('') + '<p class="hint" style="margin:6px 0 0"><a href="/workflows/new">Describe another workflow</a></p>';
691
+ }
692
+
693
+ function loadWorkflowsSection(name) {
694
+ var el = detail.querySelector('#workflowsContent');
695
+ if (!el) return;
696
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/workflows').then(function (r) {
697
+ if (currentName !== name) return;
698
+ if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
699
+ el.innerHTML = renderWorkflowsHtml(name, r.body.workflows || []);
700
+ var cards = el.querySelectorAll('.workflow-card');
701
+ for (var i = 0; i < cards.length; i++) wireWorkflowCard(name, cards[i]);
702
+ });
703
+ }
704
+
705
+ function wireWorkflowCard(name, card) {
706
+ var id = card.dataset.workflow;
707
+ var result = card.querySelector('.workflow-result');
708
+ var base = '/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(id);
709
+ card.querySelector('.workflow-run').addEventListener('click', function (ev) {
710
+ var btn = ev.currentTarget;
711
+ btn.disabled = true;
712
+ result.textContent = 'Starting' + String.fromCharCode(8230);
713
+ fetchJsonRetryOnce(base + '/run', { method: 'POST' }).then(function (r) {
714
+ btn.disabled = false;
715
+ result.textContent = r.ok
716
+ ? 'Started. Questions and approvals go to this agent' + String.fromCharCode(8217) + 's messaging app; its result shows in the Schedules tab.'
717
+ : (r.body.error || 'Could not start it.');
718
+ });
719
+ });
720
+ card.querySelector('.workflow-forget').addEventListener('click', function (ev) {
721
+ var btn = ev.currentTarget;
722
+ if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again (its skill, schedule and rules stay)'; return; }
723
+ fetchJsonRetryOnce(base, { method: 'DELETE' }).then(function () { loadWorkflowsSection(name); });
724
+ });
725
+ }
726
+
629
727
  function fillOverviewCards(name) {
630
728
  Promise.all([
631
729
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier'),
@@ -641,7 +739,7 @@ ${consoleShellScript}
641
739
  var messagingDetail = [];
642
740
  if (pending.length) messagingDetail.push(pending.length + ' waiting for a decision');
643
741
  if (n && connected && n.channel === 'slack' && n.plan !== 'free' && (!n.file || n.file.conversations !== false)) messagingDetail.push('answers replies in threads');
644
- if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Slack, Lark, email or a webhook');
742
+ if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Telegram, Slack, Lark, email or a webhook');
645
743
  if (!messagingDetail.length && n) messagingDetail.push('Sends ' + escapeHtml((n.events || []).join(', ')));
646
744
  set('ovMessagingDetail', messagingDetail.join(' \u00b7 '));
647
745
  showPendingCount(pending.length);
@@ -689,12 +787,12 @@ ${consoleShellScript}
689
787
  '<form class="add-source" id="modelForm" style="display:none">' +
690
788
  '<label>Provider' +
691
789
  '<select name="provider">' +
692
- ['anthropic', 'openai', 'deepseek', 'kimi', 'glm', 'gemini'].map(function (p) {
790
+ ['openai', 'anthropic', 'gemini', 'meta', 'xai', 'deepseek', 'kimi', 'glm'].map(function (p) {
693
791
  return '<option value="' + p + '"' + (p === cfg.model.provider ? ' selected' : '') + '>' + p + '</option>';
694
792
  }).join('') +
695
793
  '</select>' +
696
794
  '</label>' +
697
- '<label>Model name <span class="hint">(required for openai/deepseek; defaults to claude-sonnet-5 for anthropic)</span>' +
795
+ '<label>Model name <span class="hint">(choose Other&hellip; to type a model not in the list)</span>' +
698
796
  '<input type="text" name="modelName" value="' + escapeHtml(cfg.model.model || '') + '">' +
699
797
  '</label>' +
700
798
  '<label>Max tokens <span class="hint">(blank for provider default)</span>' +
@@ -714,6 +812,7 @@ ${consoleShellScript}
714
812
  }
715
813
 
716
814
  return renderOverviewCards(cfg) +
815
+ '<section id="workflowsSection"><h3>Workflows</h3><div id="workflowsContent"><p class="hint">Loading&hellip;</p></div></section>' +
717
816
  '<section>' + systemPromptSection + '</section>' +
718
817
  '<section>' + modelSection + '</section>' +
719
818
  '<section><h3>Hooks</h3><dl class="kv">' +
@@ -813,6 +912,13 @@ ${consoleShellScript}
813
912
  modelDisplay.style.display = 'none';
814
913
  editModelBtn.style.display = 'none';
815
914
  modelForm.style.display = '';
915
+ // Only providers with a key saved can be chosen; the agent's
916
+ // current one stays selectable either way. Then the model dropdown.
917
+ var currentProvider = modelForm.querySelector('select[name="provider"] option[selected]');
918
+ leShell.loadProviderKeys().then(function (keys) {
919
+ leShell.applyProviderKeys(modelForm, keys, currentProvider && currentProvider.value);
920
+ leShell.wireModelPicker(modelForm);
921
+ });
816
922
  });
817
923
  overviewPanel.querySelector('#cancelModelBtn').addEventListener('click', function () {
818
924
  modelForm.style.display = 'none';
@@ -975,6 +1081,19 @@ ${consoleShellScript}
975
1081
  });
976
1082
  }
977
1083
 
1084
+ // A decision in words, as on the homepage: Allow, Ask me (wait for a
1085
+ // person) or Never.
1086
+ var DECISION_LABELS = { allow: 'Allow', ask: 'Ask me', deny: 'Never' };
1087
+
1088
+ // Three buttons for a decision; the pressed one is current. attrs
1089
+ // says what the switch sets (a rule, or the default decision).
1090
+ function decisionSwitch(current, label, attrs) {
1091
+ return '<div class="decision-switch" role="group" aria-label="' + escapeHtml(label) + '" ' + attrs + '>' +
1092
+ ['allow', 'ask', 'deny'].map(function (d) {
1093
+ return '<button type="button" data-decision="' + d + '" aria-pressed="' + (current === d ? 'true' : 'false') + '">' + DECISION_LABELS[d] + '</button>';
1094
+ }).join('') + '</div>';
1095
+ }
1096
+
978
1097
  function renderActauthRuleRow(r) {
979
1098
  var actionsHtml = r.name
980
1099
  ? '<button type="button" class="edit-rule-btn" data-name="' + escapeHtml(r.name) + '">Edit</button> ' +
@@ -983,36 +1102,58 @@ ${consoleShellScript}
983
1102
  // update/removeActauthRule (both keyed by name) can address — left
984
1103
  // visible, but not editable here.
985
1104
  : '<span class="hint">unnamed — edit actauth.yml directly</span>';
1105
+ // Advanced's rule editor: every rule in full, name and scope included.
986
1106
  return '<tr>' +
987
1107
  '<td><code>' + escapeHtml(r.name || '') + '</code></td>' +
988
1108
  '<td><code>' + escapeHtml(r.scope) + '</code></td>' +
1109
+ '<td>' + (r.ability ? '<code>' + escapeHtml(r.ability) + '</code>' : '') + '</td>' +
989
1110
  '<td><code>' + escapeHtml(r.tool) + '</code></td>' +
990
- '<td><span class="le-pill ' + escapeHtml(r.decision) + '">' + escapeHtml(r.decision) + '</span></td>' +
1111
+ '<td>' + (r.name
1112
+ ? decisionSwitch(r.decision, 'Decision for ' + r.name, 'data-rule="' + escapeHtml(r.name) + '"')
1113
+ : '<span class="le-pill ' + escapeHtml(r.decision) + '">' + escapeHtml(DECISION_LABELS[r.decision] || r.decision) + '</span>') + '</td>' +
991
1114
  '<td>' + actionsHtml + '</td>' +
992
1115
  '</tr>';
993
1116
  }
994
1117
 
995
- function renderActauthConfigHtml(cfg) {
996
- var rulesHtml = cfg.rules.length
997
- ? '<table><thead><tr><th>Name</th><th>Who (tenant / environment)</th><th>Tool</th><th>Decision</th><th></th></tr></thead><tbody>' +
998
- cfg.rules.map(renderActauthRuleRow).join('') + '</tbody></table>'
999
- : '<p class="hint">No explicit rules — every tool call falls through to the default decision.</p>';
1000
-
1001
- return '<p class="muted" style="margin:0 0 20px">Decide which tool calls run on their own, which wait for a person, and which never run. Stored in <code>actauth.yml</code> in this agent&#39;s folder.</p>' +
1002
- '<section><h3>Rules (' + cfg.rules.length + ')</h3>' + rulesHtml + '</section>' +
1118
+ // The Permissions tab: every tool the agent has, each with its ability
1119
+ // and Allow / Ask me / Never (saved at once: its rule for everyone is
1120
+ // updated, or added), then the decision when no rule matches. Rules in
1121
+ // full (scopes, names, adding and removing) are in Advanced.
1122
+ function renderPermissionsHtml(cfg) {
1123
+ var tools = cfg.tools || [];
1124
+ var rows = tools.map(function (t) {
1125
+ var decision = t.editable
1126
+ ? decisionSwitch(t.decision, 'Decision for ' + t.tool, 'data-tool="' + escapeHtml(t.tool) + '"')
1127
+ : '<span class="le-pill ' + escapeHtml(t.decision) + '">' + escapeHtml(DECISION_LABELS[t.decision] || t.decision) + '</span>';
1128
+ var note = t.isDefault ? 'default' : t.scopeNote;
1129
+ return '<tr>' +
1130
+ '<td>' + (t.ability ? '<code>' + escapeHtml(t.ability) + '</code>' : '') + '</td>' +
1131
+ '<td><code>' + escapeHtml(t.tool) + '</code></td>' +
1132
+ '<td>' + decision + (note ? ' <span class="hint">' + escapeHtml(note) + '</span>' : '') + '</td>' +
1133
+ '</tr>';
1134
+ }).join('');
1135
+ return '<p class="muted" style="margin:0 0 20px">Decide which tool calls run on their own (Allow), which wait for a person (Ask me), and which never run (Never). Click to change; it&#39;s saved at once. Scoped rules, adding and removing rules are in <a href="?tab=advanced" data-tab="advanced">Advanced</a>.</p>' +
1136
+ '<section><h3>Tools (' + tools.length + ')</h3>' +
1137
+ (tools.length
1138
+ ? '<div class="le-table-wrap"><table class="actauth-rules"><thead><tr><th>Ability</th><th>Tool</th><th>Decision</th></tr></thead><tbody>' + rows + '</tbody></table></div>'
1139
+ : '<p class="hint">This agent has no tools yet. Install an ability, or add a tool in Advanced.</p>') +
1140
+ '<p class="error" id="toolsDecisionError" style="margin:6px 0 0"></p></section>' +
1003
1141
  '<section><h3>When no rule matches</h3>' +
1004
- '<form class="add-source" id="defaultDecisionForm" style="max-width:260px">' +
1005
- '<label>Decision' +
1006
- '<select name="defaultDecision" id="defaultDecisionSelect">' +
1007
- '<option value="allow"' + (cfg.defaultDecision === 'allow' ? ' selected' : '') + '>allow</option>' +
1008
- '<option value="ask"' + (cfg.defaultDecision === 'ask' ? ' selected' : '') + '>ask</option>' +
1009
- '<option value="deny"' + (cfg.defaultDecision === 'deny' ? ' selected' : '') + '>deny</option>' +
1010
- '</select>' +
1011
- '</label>' +
1012
- '<button type="submit">Save</button>' +
1013
- '<div id="defaultDecisionError" class="error"></div>' +
1014
- '</form></section>' +
1015
- '<section><h3 id="ruleFormHeading">Add a rule</h3>' +
1142
+ decisionSwitch(cfg.defaultDecision, 'Decision when no rule matches', 'id="defaultDecisionSwitch"') +
1143
+ '<p class="hint" style="margin:6px 0 0">For a tool with no rule (shown as default above), and any call no rule covers.</p>' +
1144
+ '<p class="error" id="defaultDecisionError" style="margin:4px 0 0"></p></section>';
1145
+ }
1146
+
1147
+ // Advanced's Permission rules: every rule in actauth.yml, with its name
1148
+ // and scope, to edit, remove or add.
1149
+ function renderRulesEditorHtml(cfg) {
1150
+ var rulesHtml = cfg.rules.length
1151
+ ? '<div class="le-table-wrap"><table class="actauth-rules actauth-rules-full"><thead><tr><th>Name</th><th>Who (tenant / environment)</th><th>Ability</th><th>Tool</th><th>Decision</th><th></th></tr></thead><tbody>' +
1152
+ cfg.rules.map(renderActauthRuleRow).join('') + '</tbody></table></div>'
1153
+ : '<p class="hint">No rules yet: every tool call gets the decision for when no rule matches.</p>';
1154
+ return '<p class="muted" style="margin:0 0 12px">Every rule in <code>actauth.yml</code>. The first rule that matches a call decides it; Who narrows a rule to a tenant and environment (<code>*/*</code> is everyone).</p>' +
1155
+ rulesHtml + '<p class="error" id="rulesError" style="margin:6px 0 0"></p>' +
1156
+ '<h3 id="ruleFormHeading" style="margin-top:20px">Add a rule</h3>' +
1016
1157
  '<form class="add-source" id="ruleForm">' +
1017
1158
  '<label>Name' +
1018
1159
  '<input name="name" id="ruleNameInput" required placeholder="my-rule-name">' +
@@ -1025,9 +1166,9 @@ ${consoleShellScript}
1025
1166
  '</label>' +
1026
1167
  '<label>Decision' +
1027
1168
  '<select name="decision" id="ruleDecisionInput">' +
1028
- '<option value="allow">allow</option>' +
1029
- '<option value="ask" selected>ask</option>' +
1030
- '<option value="deny">deny</option>' +
1169
+ '<option value="allow">Allow</option>' +
1170
+ '<option value="ask" selected>Ask me</option>' +
1171
+ '<option value="deny">Never</option>' +
1031
1172
  '</select>' +
1032
1173
  '</label>' +
1033
1174
  '<button type="submit" id="ruleSubmitBtn">Add</button>' +
@@ -1071,12 +1212,44 @@ ${consoleShellScript}
1071
1212
  }).join('') + '</tbody></table></div>';
1072
1213
  }
1073
1214
 
1215
+ // The Messaging tab lists one section per app; each is set up and
1216
+ // saved on its own. A webhook always sends once set up; of the
1217
+ // messaging apps, only the primary one does.
1218
+ var CHANNEL_TITLES = { webhook: 'Webhook', telegram: 'Telegram', slack: 'Slack', lark: 'Lark / Feishu', email: 'Email' };
1219
+ // Each app's icon (web/messaging-icons.ts).
1220
+ var CHANNEL_ICONS = ${JSON.stringify(MESSAGING_ICONS)};
1221
+ var CHANNEL_BLURBS = {
1222
+ webhook: 'Your own app receives each request as a signed POST.',
1223
+ telegram: 'A Telegram bot posts requests to a group, channel or person.',
1224
+ slack: 'A Slack app posts requests to a channel, with buttons and replies in threads.',
1225
+ lark: 'A Lark or Feishu app posts requests to a chat.',
1226
+ email: 'Requests are emailed through Resend, SendGrid or Postmark.'
1227
+ };
1228
+
1229
+ function connectionFor(n, channel) {
1230
+ var list = n.connections || [];
1231
+ for (var i = 0; i < list.length; i++) if (list[i].channel === channel) return list[i];
1232
+ return null;
1233
+ }
1234
+
1074
1235
  function renderNotifierStatus(n) {
1075
- if (n.source === 'code') return 'Set in this agent&#39;s <code>index.ts</code> (<code>httpNotifier</code>, ' + escapeHtml(n.channel) + '). Edit it there; code always wins over the form below.';
1076
- if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
1077
- if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
1078
- 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.';
1079
- return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a Slack channel, Lark chat, email address or webhook below.';
1236
+ if (n.source === 'code') return 'Set in this agent&#39;s <code>index.ts</code> (<code>httpNotifier</code>, ' + escapeHtml(n.channel) + '). Edit it there; code always wins over the connections below.';
1237
+ var rule = ' A webhook always sends once set up; of Telegram, Slack, Lark and email, only the primary one does.';
1238
+ var active = n.active || [];
1239
+ var sending = active.filter(function (a) { return !a.missingSecret; }).map(function (a) {
1240
+ return a.channel === 'webhook' ? 'your <strong>webhook</strong>' : '<strong>' + escapeHtml(CHANNEL_TITLES[a.channel] || a.channel) + '</strong> (primary)';
1241
+ });
1242
+ var waiting = active.filter(function (a) { return a.missingSecret; }).map(function (a) {
1243
+ return escapeHtml(CHANNEL_TITLES[a.channel] || a.channel) + ' is waiting for its secret (<code>' + escapeHtml(a.missingSecret) + '</code>)';
1244
+ });
1245
+ var appsSaved = (n.connections || []).some(function (c) { return c.channel !== 'webhook'; });
1246
+ var noPrimary = appsSaved && !n.primary ? ' No messaging app is primary: make one primary below.' : '';
1247
+ var html = sending.length ? 'Sending to ' + sending.join(' and ') + '.' : '';
1248
+ if (waiting.length) html += (html ? ' ' : '') + waiting.join('; ') + '.';
1249
+ if (html) return html + noPrimary + rule;
1250
+ var fallback = n.source === 'default' ? ' Until then the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + ') is used.' : ' Until then nobody is notified.';
1251
+ if (appsSaved) return '<strong>Nothing is sending.</strong>' + noPrimary + fallback + rule;
1252
+ return '<strong>Nowhere yet.</strong> Set up an app below; the first messaging app you save becomes primary.' + fallback + rule;
1080
1253
  }
1081
1254
 
1082
1255
  // Slack only: whether replies in a request's thread are answered. On
@@ -1092,43 +1265,52 @@ ${consoleShellScript}
1092
1265
  : 'People can ask the agent about a request in its thread, and it answers there. Approvers (below) can also decide it there.') + '</p></div>';
1093
1266
  }
1094
1267
 
1095
- function renderNotifierForm(n) {
1096
- var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
1097
- var events = f.events || [];
1098
- function eventBox(e) {
1099
- 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>';
1100
- }
1101
- function field(channel, html) {
1102
- return '<div class="notifier-fields" data-channel="' + channel + '"' + (f.channel === channel ? '' : ' hidden') + '>' + html + '</div>';
1103
- }
1104
- // A secret's state at a glance: a pill by the label, and a placeholder
1105
- // saying whether typing replaces a saved one. Its value is never shown.
1106
- function secretPill(isSet) {
1107
- return isSet ? '<span class="le-pill good">Saved</span>' : '<span class="le-pill warn">Not set</span>';
1108
- }
1109
- // The field's name and its pill on one line (form labels stack their
1110
- // children, which would stretch the pill across the row).
1111
- function secretHead(name, isSet) {
1112
- return '<span style="display:flex;align-items:center;gap:8px">' + name + secretPill(isSet) + '</span>';
1113
- }
1114
- function secretPlaceholder(isSet, missing) {
1115
- return isSet ? 'Saved ' + String.fromCharCode(8212) + ' type a new one only to replace it' : missing;
1116
- }
1117
- var SECRET_HINTS = { slack: 'xoxb-' + String.fromCharCode(8230) + ' from OAuth & Permissions', webhook: 'A long random string, or Generate', lark: 'App Secret from the Lark developer console', email: 'An API key from your email provider' };
1118
- function secretField(channel, label) {
1119
- var isSet = n.secretsSet && n.secretsSet[channel];
1120
- return '<label>' + secretHead(label, isSet) + '<span class="hint">(stored in this agent&#39;s env as ' + escapeHtml(n.secretVars[channel]) + ')</span>' +
1121
- '<span style="display:flex;gap:6px"><input type="password" name="secret_' + channel + '" autocomplete="off" style="flex:1;min-width:0" placeholder="' + escapeHtml(secretPlaceholder(isSet, SECRET_HINTS[channel] || '')) + '">' +
1122
- (channel === 'webhook' ? '<button type="button" class="notifier-generate">Generate</button>' : '') + '</span></label>';
1268
+ // A secret's state at a glance: a pill by the label, and a placeholder
1269
+ // saying whether typing replaces a saved one. Its value is never shown.
1270
+ function secretPill(isSet) {
1271
+ return isSet ? '<span class="le-pill good">Saved</span>' : '<span class="le-pill warn">Not set</span>';
1272
+ }
1273
+ // The field's name and its pill on one line (form labels stack their
1274
+ // children, which would stretch the pill across the row).
1275
+ function secretHead(name, isSet) {
1276
+ return '<span style="display:flex;align-items:center;gap:8px">' + name + secretPill(isSet) + '</span>';
1277
+ }
1278
+ function secretPlaceholder(isSet, missing) {
1279
+ return isSet ? 'Saved ' + String.fromCharCode(8212) + ' type a new one only to replace it' : missing;
1280
+ }
1281
+ var SECRET_HINTS = {
1282
+ slack: 'xoxb-' + String.fromCharCode(8230) + ' from OAuth & Permissions',
1283
+ webhook: 'A long random string, or Generate',
1284
+ telegram: 'The token @BotFather gave you, like 123456:ABC' + String.fromCharCode(8230),
1285
+ lark: 'App Secret from the Lark developer console',
1286
+ email: 'An API key from your email provider'
1287
+ };
1288
+ function secretField(n, channel, label) {
1289
+ var isSet = n.secretsSet && n.secretsSet[channel];
1290
+ return '<label>' + secretHead(label, isSet) + '<span class="hint">(stored in this agent&#39;s env as ' + escapeHtml(n.secretVars[channel]) + ')</span>' +
1291
+ '<span style="display:flex;gap:6px"><input type="password" name="secret" autocomplete="off" style="flex:1;min-width:0" placeholder="' + escapeHtml(secretPlaceholder(isSet, SECRET_HINTS[channel] || '')) + '">' +
1292
+ (channel === 'webhook' ? '<button type="button" class="notifier-generate">Generate</button>' : '') + '</span></label>';
1293
+ }
1294
+
1295
+ // The settings only this app has.
1296
+ function renderChannelFields(n, channel, f) {
1297
+ if (channel === 'webhook') {
1298
+ return '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField(n, 'webhook', 'Signing secret') +
1299
+ '<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 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>';
1123
1300
  }
1124
- return '<form class="add-source" id="notifierForm" style="max-width:560px">' +
1125
- '<label>Send to<select name="channel" id="notifierChannel">' +
1126
- ['webhook', 'slack', 'lark', 'email'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat', email: 'Email' }[c] + '</option>'; }).join('') +
1127
- '</select></label>' +
1128
- 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') +
1129
- '<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 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>') +
1130
- field('slack',
1131
- '<details class="slack-step"' + (n.secretsSet && n.secretsSet.slack ? '' : ' open') + '><summary>Create the Slack app (once)</summary>' +
1301
+ if (channel === 'telegram') {
1302
+ return '<details class="slack-step"' + (n.secretsSet && n.secretsSet.telegram ? '' : ' open') + '><summary>Create the Telegram bot (once)</summary>' +
1303
+ '<ol class="hint" style="margin:6px 0;padding-left:18px">' +
1304
+ '<li>Message <a href="https://t.me/BotFather" target="_blank" rel="noopener">@BotFather</a>, send /newbot and follow the steps. Paste the token it gives you below.</li>' +
1305
+ '<li>Add the bot to the group or channel that should get requests (as an admin, for a channel), or start a chat with it yourself.</li>' +
1306
+ '<li>Find the chat id: send a message there, then open https://api.telegram.org/bot&lt;token&gt;/getUpdates and copy <code>chat.id</code>. A public channel can use @its-name instead.</li>' +
1307
+ '</ol></details>' +
1308
+ secretField(n, 'telegram', 'Bot token') +
1309
+ '<label>Chat ID <span class="hint">(a group or channel id like -1001234567890, or @channelname)</span><input name="chatId" placeholder="-1001234567890" value="' + escapeHtml(f.chatId || '') + '"></label>' +
1310
+ '<p class="hint" style="margin:0">Each message links to a page where the person approves, denies or answers; there are no buttons in Telegram yet.</p>';
1311
+ }
1312
+ if (channel === 'slack') {
1313
+ return '<details class="slack-step"' + (n.secretsSet && n.secretsSet.slack ? '' : ' open') + '><summary>Create the Slack app (once)</summary>' +
1132
1314
  '<ol class="hint" style="margin:6px 0;padding-left:18px">' +
1133
1315
  '<li>Open <a href="https://api.slack.com/apps?new_app=1" target="_blank" rel="noopener">Slack apps</a> &rarr; Create New App &rarr; From a manifest, and pick your workspace.</li>' +
1134
1316
  '<li>Paste the manifest below, create the app, then Install to Workspace.</li>' +
@@ -1137,7 +1319,7 @@ ${consoleShellScript}
1137
1319
  '<textarea id="slackManifest" readonly rows="8" style="font-family:var(--font-mono);font-size:11.5px"></textarea>' +
1138
1320
  '<button type="button" id="slackManifestCopy" style="margin-top:6px">Copy manifest</button>' +
1139
1321
  '</details>' +
1140
- secretField('slack', 'Bot token') +
1322
+ secretField(n, 'slack', 'Bot token') +
1141
1323
  '<label>Channel <span class="hint">(invite the bot to a private channel first: /invite @its-name)</span>' +
1142
1324
  '<span style="display:flex;gap:6px"><select id="slackChannelSelect" style="flex:1;min-width:0"><option value="">' + (f.channelId ? 'Saved: ' + escapeHtml(f.channelId) : 'Load channels to pick one') + '</option></select>' +
1143
1325
  '<button type="button" id="slackLoadChannels">Load channels</button></span></label>' +
@@ -1147,29 +1329,74 @@ ${consoleShellScript}
1147
1329
  '<input type="password" name="signingSecret" autocomplete="off" placeholder="' + escapeHtml(secretPlaceholder(n.slack && n.slack.signingSecretSet, 'Paste the Signing Secret')) + '"></label>' +
1148
1330
  '<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>' +
1149
1331
  renderConversationsField(n, f) +
1150
- '<p class="hint" style="margin:0" id="slackEventsNote"></p>') +
1151
- 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')) +
1152
- field('email',
1153
- '<label>Email addresses <span class="hint">(who gets the requests; separate several with commas)</span><input name="emailTo" placeholder="ops@example.com, lead@example.com" value="' + escapeHtml(f.to || '') + '"></label>' +
1154
- '<label>Sent by <span class="hint">(your email provider must allow sending from this address)</span><input name="emailFrom" placeholder="LoopEngine &lt;approvals@example.com&gt;" value="' + escapeHtml(f.from || '') + '"></label>' +
1155
- '<label>Email provider<select name="emailProvider">' +
1156
- [['resend', 'Resend'], ['sendgrid', 'SendGrid'], ['postmark', 'Postmark']].map(function (p) { return '<option value="' + p[0] + '"' + (f.provider === p[0] ? ' selected' : '') + '>' + p[1] + '</option>'; }).join('') +
1157
- '</select></label>' +
1158
- secretField('email', 'API key') +
1159
- '<p class="hint" style="margin:0">Each email links to a page where the person approves, denies, edits or answers, with no login. The link address below must be one they can open.</p>') +
1160
- '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
1161
- '<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>' +
1162
- '<div class="body-field">Send' +
1163
- '<div style="display:flex;gap:14px;flex-wrap:wrap">' + NOTIFIER_EVENTS.slice(0, 2).map(eventBox).join('') + '</div>' +
1164
- '<details' + (events.indexOf('run_finish') !== -1 || events.indexOf('run_start') !== -1 ? ' open' : '') + '><summary>More events</summary>' +
1165
- '<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">' + NOTIFIER_EVENTS.slice(2).map(eventBox).join('') + '</div>' +
1166
- '<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>' +
1167
- (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>' : '') +
1168
- '<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>' +
1169
- (n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
1170
- '<div id="notifierResult" class="hint"></div>' +
1171
-
1172
- '</form>';
1332
+ '<p class="hint" style="margin:0" id="slackEventsNote"></p>';
1333
+ }
1334
+ if (channel === 'lark') {
1335
+ return '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField(n, 'lark', 'App secret');
1336
+ }
1337
+ return '<label>Email addresses <span class="hint">(who gets the requests; separate several with commas)</span><input name="emailTo" placeholder="ops@example.com, lead@example.com" value="' + escapeHtml(f.to || '') + '"></label>' +
1338
+ '<label>Sent by <span class="hint">(your email provider must allow sending from this address)</span><input name="emailFrom" placeholder="LoopEngine &lt;approvals@example.com&gt;" value="' + escapeHtml(f.from || '') + '"></label>' +
1339
+ '<label>Email provider<select name="emailProvider">' +
1340
+ [['resend', 'Resend'], ['sendgrid', 'SendGrid'], ['postmark', 'Postmark']].map(function (p) { return '<option value="' + p[0] + '"' + (f.provider === p[0] ? ' selected' : '') + '>' + p[1] + '</option>'; }).join('') +
1341
+ '</select></label>' +
1342
+ secretField(n, 'email', 'API key') +
1343
+ '<p class="hint" style="margin:0">Each email links to a page where the person approves, denies, edits or answers, with no login. The link address below must be one they can open.</p>';
1344
+ }
1345
+
1346
+ // A messaging app's form says whether it's the primary one, or offers
1347
+ // to make it primary on Save (ticked already when none is). The webhook
1348
+ // has neither: it always sends.
1349
+ function renderPrimaryField(n, channel, saved, isPrimary) {
1350
+ if (channel === 'webhook') return '';
1351
+ if (isPrimary) return '<p class="hint" style="margin:0"><span class="le-pill accent">Primary</span> This is the primary messaging app. To switch, tick &ldquo;Make this the primary messaging app&rdquo; in another app&#39;s section and save it.</p>';
1352
+ return '<div class="body-field" style="margin:0">' +
1353
+ '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="makePrimary"' + (n.primary ? '' : ' checked') + '> Make this the primary messaging app</label>' +
1354
+ '<p class="hint" style="margin:2px 0 0">' + (n.primary
1355
+ ? 'Only the primary messaging app sends; now that&#39;s ' + escapeHtml(CHANNEL_TITLES[n.primary] || n.primary) + '. ' + (saved ? 'Send test works either way.' : 'Leave this off to save ' + escapeHtml(CHANNEL_TITLES[channel] || channel) + ' for later.')
1356
+ : 'No messaging app is primary yet, so saving this one makes it primary.') + '</p></div>';
1357
+ }
1358
+
1359
+ // One app's section: its status in the summary, then its own form.
1360
+ function renderConnectionSection(n, channel) {
1361
+ var saved = connectionFor(n, channel);
1362
+ var f = saved || { channel: channel, events: ['approval', 'question'] };
1363
+ var isPrimary = n.primary === channel;
1364
+ var isWebhook = channel === 'webhook';
1365
+ var secretSet = n.secretsSet && n.secretsSet[channel];
1366
+ var pills = isPrimary ? '<span class="le-pill accent">Primary</span>' : isWebhook && saved ? '<span class="le-pill accent">Always on</span>' : '';
1367
+ pills += saved ? (secretSet ? ' <span class="le-pill good">Saved</span>' : ' <span class="le-pill warn">Secret not set</span>') : ' <span class="le-pill">Not set up</span>';
1368
+ var events = f.events || [];
1369
+ function eventBox(e) {
1370
+ 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>';
1371
+ }
1372
+ var locked = n.canSave === false;
1373
+ // Every section starts closed; loadApprovalsSection reopens the ones
1374
+ // that were open before a save redrew the tab.
1375
+ var inUse = isPrimary || (isWebhook && saved);
1376
+ return '<details class="msg-conn' + (inUse ? ' is-primary' : '') + '" data-channel="' + channel + '">' +
1377
+ '<summary><span class="msg-conn-title">' + (CHANNEL_ICONS[channel] || '') + escapeHtml(CHANNEL_TITLES[channel] || channel) + '</span>' + pills +
1378
+ '<span class="hint msg-conn-blurb">' + escapeHtml(CHANNEL_BLURBS[channel] || '') + '</span></summary>' +
1379
+ '<form class="add-source notifier-form" data-channel="' + channel + '" style="max-width:560px">' +
1380
+ renderChannelFields(n, channel, f) +
1381
+ '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
1382
+ '<input type="url" name="publicUrl" class="notifier-public-url" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
1383
+ '<div class="body-field">Send' +
1384
+ '<div style="display:flex;gap:14px;flex-wrap:wrap">' + NOTIFIER_EVENTS.slice(0, 2).map(eventBox).join('') + '</div>' +
1385
+ '<details' + (events.indexOf('run_finish') !== -1 || events.indexOf('run_start') !== -1 ? ' open' : '') + '><summary>More events</summary>' +
1386
+ '<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">' + NOTIFIER_EVENTS.slice(2).map(eventBox).join('') + '</div>' +
1387
+ '<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>' +
1388
+ renderPrimaryField(n, channel, saved, isPrimary) +
1389
+ '<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit"' + (locked ? ' disabled' : '') + '>' + (saved ? 'Save' : 'Save ' + escapeHtml(CHANNEL_TITLES[channel] || channel)) + '</button>' +
1390
+ (saved ? '<button type="button" class="notifier-test">Send test</button>' : '') +
1391
+ (saved ? '<button type="button" class="delete-btn notifier-remove"' + (locked ? ' disabled' : '') + '>Remove</button>' : '') + '</div>' +
1392
+ (isWebhook ? '<p class="hint" style="margin:0">A webhook always sends once saved, alongside the primary messaging app.</p>' : '') +
1393
+ '<div class="hint notifier-result"></div>' +
1394
+ '</form></details>';
1395
+ }
1396
+
1397
+ function renderConnectionsHtml(n) {
1398
+ return (n.canSave === false ? '<p class="error" style="margin:0 0 12px">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>' : '') +
1399
+ '<div class="msg-conns">' + (n.channels || ['slack', 'telegram', 'lark', 'email', 'webhook']).map(function (c) { return renderConnectionSection(n, c); }).join('') + '</div>';
1173
1400
  }
1174
1401
 
1175
1402
  function renderDeliveries(list) {
@@ -1207,15 +1434,18 @@ ${consoleShellScript}
1207
1434
  }
1208
1435
 
1209
1436
  // Slack only: the people who can decide by replying in a request's
1210
- // thread, and whose button clicks count once any are listed.
1437
+ // thread, and whose button clicks count once any are listed. Shown once
1438
+ // a Slack connection is saved; they only act while Slack is primary.
1211
1439
  function renderApproversHtml(n) {
1212
- if (n.source === 'code' || !n.file || n.file.channel !== 'slack') return '';
1213
- var approvers = n.file.approvers || [];
1440
+ var slack = connectionFor(n, 'slack');
1441
+ if (n.source === 'code' || !slack) return '';
1442
+ var approvers = slack.approvers || [];
1214
1443
  var rows = approvers.map(function (a) {
1215
1444
  return '<tr><td>' + escapeHtml(a.name || a.user) + '</td><td><code>' + escapeHtml(a.user) + '</code></td>' +
1216
1445
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
1217
1446
  }).join('');
1218
- return '<section id="approversSection"><h3>Approvers</h3>' +
1447
+ return '<section id="approversSection"><h3>Slack approvers</h3>' +
1448
+ (n.primary !== 'slack' ? '<p class="hint" style="margin:0 0 8px">Slack isn&#39;t primary, so these take effect once it is.</p>' : '') +
1219
1449
  '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A 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 too. Anyone in the channel can still ask about a request.</p>' +
1220
1450
  (approvers.length
1221
1451
  ? '<div class="le-table-wrap" style="max-width:560px"><table><thead><tr><th>Name</th><th>Slack ID</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
@@ -1231,7 +1461,8 @@ ${consoleShellScript}
1231
1461
  var section = detail.querySelector('#approversSection');
1232
1462
  if (!section) return;
1233
1463
  var result = section.querySelector('#approversResult');
1234
- var current = (n.file && n.file.approvers) || [];
1464
+ var slack = connectionFor(n, 'slack');
1465
+ var current = (slack && slack.approvers) || [];
1235
1466
  function save(list, message) {
1236
1467
  result.textContent = 'Saving' + String.fromCharCode(8230);
1237
1468
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/approvers', {
@@ -1278,10 +1509,10 @@ ${consoleShellScript}
1278
1509
 
1279
1510
  function renderApprovalsHtml(n) {
1280
1511
  return '<p class="muted" style="margin:0 0 20px">Connect a messaging app, so this agent can reach people when nobody is in Chat. When an app calls it over HTTP or a scheduled task runs, and a tool call needs approval or the agent asks a question, the request is sent here. In Chat it shows up in the conversation instead.</p>' +
1281
- '<section id="approvalsSetup"><h3>Connection</h3>' +
1282
- '<p class="muted" style="margin:0 0 12px">Currently: ' + renderNotifierStatus(n) + '</p>' +
1512
+ '<section id="approvalsSetup"><h3>Connections</h3>' +
1513
+ '<p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
1283
1514
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
1284
- (n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
1515
+ (n.source === 'code' ? '' : renderConnectionsHtml(n)) + '</section>' +
1285
1516
  renderApproversHtml(n) +
1286
1517
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
1287
1518
  }
@@ -1324,28 +1555,91 @@ ${consoleShellScript}
1324
1555
  function wireApprovalsHandlers(name) {
1325
1556
  var content = approvalsContentEl();
1326
1557
  if (!content) return;
1327
- var form = content.querySelector('#notifierForm');
1328
- if (!form) return;
1329
- var result = content.querySelector('#notifierResult');
1330
- content.querySelector('#notifierChannel').addEventListener('change', function (ev) {
1331
- var groups = form.querySelectorAll('.notifier-fields');
1332
- for (var k = 0; k < groups.length; k++) groups[k].hidden = groups[k].dataset.channel !== ev.currentTarget.value;
1333
- });
1558
+ var forms = content.querySelectorAll('.notifier-form');
1559
+ for (var i = 0; i < forms.length; i++) wireConnectionForm(name, forms[i]);
1560
+ }
1561
+
1562
+ // One app's section: Save (with its "make primary" box), Send test and Remove act on
1563
+ // that app's connection only.
1564
+ function wireConnectionForm(name, form) {
1565
+ var channel = form.dataset.channel;
1566
+ var result = form.querySelector('.notifier-result');
1567
+ var base = '/agents/' + encodeURIComponent(name) + '/notifier';
1334
1568
  var generate = form.querySelector('.notifier-generate');
1335
1569
  if (generate) {
1336
1570
  generate.addEventListener('click', function () {
1337
1571
  var bytes = new Uint8Array(24);
1338
1572
  crypto.getRandomValues(bytes);
1339
- var input = form.querySelector('input[name="secret_webhook"]');
1573
+ var input = form.querySelector('input[name="secret"]');
1340
1574
  input.type = 'text';
1341
1575
  input.value = Array.prototype.map.call(bytes, function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
1342
1576
  result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
1343
1577
  });
1344
1578
  }
1345
- // Slack: manifest follows the link address; Load channels fills the picker.
1579
+ if (channel === 'slack') wireSlackFields(name, form, result);
1580
+
1581
+ form.addEventListener('submit', function (ev) {
1582
+ ev.preventDefault();
1583
+ var data = new FormData(form);
1584
+ var body = { channel: channel, events: data.getAll('events'), publicUrl: data.get('publicUrl') };
1585
+ if (channel === 'webhook') body.url = data.get('url');
1586
+ if (channel === 'telegram') body.chatId = data.get('chatId');
1587
+ if (channel === 'slack') body.channelId = data.get('channelId');
1588
+ if (channel === 'lark') { body.appId = data.get('appId'); body.chatId = data.get('chatId'); }
1589
+ if (channel === 'email') { body.to = data.get('emailTo'); body.from = data.get('emailFrom'); body.provider = data.get('emailProvider'); }
1590
+ if (data.get('secret')) body.secret = data.get('secret');
1591
+ if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
1592
+ if (channel === 'slack') {
1593
+ var conversationsBox = form.querySelector('#slackConversations');
1594
+ var savedSlack = lastNotifier && connectionFor(lastNotifier, 'slack');
1595
+ // A locked switch (free plan) keeps whatever was saved before.
1596
+ if (conversationsBox && !conversationsBox.disabled) body.conversations = conversationsBox.checked;
1597
+ else if (savedSlack && savedSlack.conversations !== undefined) body.conversations = savedSlack.conversations;
1598
+ }
1599
+ // Ticked "Make this the primary messaging app" (or none is primary yet,
1600
+ // which the server treats the same) makes this one primary.
1601
+ var makePrimaryBox = form.querySelector('input[name="makePrimary"]');
1602
+ if (makePrimaryBox && makePrimaryBox.checked) body.primary = true;
1603
+ var first = Boolean(makePrimaryBox && makePrimaryBox.checked) || (channel !== 'webhook' && lastNotifier && !lastNotifier.primary);
1604
+ result.textContent = 'Saving' + String.fromCharCode(8230);
1605
+ fetchJsonRetryOnce(base, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) {
1606
+ if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
1607
+ loadApprovalsSection(name, first ? CHANNEL_TITLES[channel] + ' saved and made primary.' : CHANNEL_TITLES[channel] + ' saved.');
1608
+ });
1609
+ });
1610
+
1611
+ var testBtn = form.querySelector('.notifier-test');
1612
+ if (testBtn) {
1613
+ testBtn.addEventListener('click', function () {
1614
+ result.textContent = 'Sending' + String.fromCharCode(8230);
1615
+ fetchJsonRetryOnce(base + '/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ channel: channel }) }).then(function (r) {
1616
+ var t = r.body;
1617
+ result.textContent = t.ok ? 'Test delivered (' + (t.status || 'ok') + ').' : 'Test failed: ' + (t.error || 'unknown error');
1618
+ });
1619
+ });
1620
+ }
1621
+
1622
+ var removeBtn = form.querySelector('.notifier-remove');
1623
+ if (removeBtn) {
1624
+ removeBtn.addEventListener('click', function () {
1625
+ if (removeBtn.dataset.confirming !== '1') {
1626
+ removeBtn.dataset.confirming = '1';
1627
+ removeBtn.textContent = lastNotifier && lastNotifier.primary === channel ? 'Click again: no messaging app will be primary' : 'Click again to remove';
1628
+ return;
1629
+ }
1630
+ fetchJsonRetryOnce(base + '?channel=' + encodeURIComponent(channel), { method: 'DELETE' }).then(function () {
1631
+ loadApprovalsSection(name, CHANNEL_TITLES[channel] + ' removed.');
1632
+ });
1633
+ });
1634
+ }
1635
+ }
1636
+
1637
+ // Slack's section: the manifest follows the link address; Load channels
1638
+ // fills the picker.
1639
+ function wireSlackFields(name, form, result) {
1346
1640
  var n0 = lastNotifier;
1347
1641
  var manifestEl = form.querySelector('#slackManifest');
1348
- var linkInput = form.querySelector('#notifierPublicUrl');
1642
+ var linkInput = form.querySelector('.notifier-public-url');
1349
1643
  function refreshSlackManifest() {
1350
1644
  if (!manifestEl || !n0 || !n0.slack) return;
1351
1645
  var url = (linkInput.value || location.origin).replace(/[/]+$/, '') + n0.slack.interactivityPath;
@@ -1373,63 +1667,26 @@ ${consoleShellScript}
1373
1667
  }
1374
1668
  var loadChannels = form.querySelector('#slackLoadChannels');
1375
1669
  var channelSelect = form.querySelector('#slackChannelSelect');
1376
- if (loadChannels) {
1377
- loadChannels.addEventListener('click', function () {
1378
- loadChannels.disabled = true;
1379
- result.textContent = 'Loading channels' + String.fromCharCode(8230);
1380
- var token = form.querySelector('input[name="secret_slack"]').value;
1381
- fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/slack-channels', {
1382
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ botToken: token }),
1383
- }).then(function (r) {
1384
- loadChannels.disabled = false;
1385
- if (!r.ok) { result.textContent = r.body.error || 'Could not load channels'; return; }
1386
- var current = form.querySelector('input[name="channelId"]').value;
1387
- channelSelect.innerHTML = '<option value="">Pick a channel</option>' + r.body.channels.map(function (c) {
1388
- return '<option value="' + escapeHtml(c.id) + '"' + (c.id === current ? ' selected' : '') + '>' + (c.isPrivate ? '&#128274; ' : '#') + escapeHtml(c.name) + (c.isMember ? '' : ' (bot not in channel yet)') + '</option>';
1389
- }).join('');
1390
- result.textContent = r.body.channels.length + ' channels found.';
1391
- });
1392
- });
1393
- channelSelect.addEventListener('change', function () {
1394
- if (channelSelect.value) form.querySelector('input[name="channelId"]').value = channelSelect.value;
1395
- });
1396
- }
1397
-
1398
- form.addEventListener('submit', function (ev) {
1399
- ev.preventDefault();
1400
- var data = new FormData(form);
1401
- var channel = data.get('channel');
1402
- 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') };
1403
- if (channel === 'email') { body.to = data.get('emailTo'); body.from = data.get('emailFrom'); body.provider = data.get('emailProvider'); }
1404
- var secret = data.get('secret_' + channel);
1405
- if (secret) body.secret = secret;
1406
- if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
1407
- var conversationsBox = form.querySelector('#slackConversations');
1408
- // A locked switch (free plan) keeps whatever was saved before.
1409
- if (channel === 'slack' && conversationsBox && !conversationsBox.disabled) body.conversations = conversationsBox.checked;
1410
- else if (channel === 'slack' && lastNotifier && lastNotifier.file && lastNotifier.file.conversations !== undefined) body.conversations = lastNotifier.file.conversations;
1411
- result.textContent = 'Saving…';
1412
- fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
1413
- method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
1670
+ if (!loadChannels) return;
1671
+ loadChannels.addEventListener('click', function () {
1672
+ loadChannels.disabled = true;
1673
+ result.textContent = 'Loading channels' + String.fromCharCode(8230);
1674
+ var token = form.querySelector('input[name="secret"]').value;
1675
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/slack-channels', {
1676
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ botToken: token }),
1414
1677
  }).then(function (r) {
1415
- if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
1416
- loadApprovalsSection(name, 'Saved.');
1678
+ loadChannels.disabled = false;
1679
+ if (!r.ok) { result.textContent = r.body.error || 'Could not load channels'; return; }
1680
+ var current = form.querySelector('input[name="channelId"]').value;
1681
+ channelSelect.innerHTML = '<option value="">Pick a channel</option>' + r.body.channels.map(function (c) {
1682
+ return '<option value="' + escapeHtml(c.id) + '"' + (c.id === current ? ' selected' : '') + '>' + (c.isPrivate ? '&#128274; ' : '#') + escapeHtml(c.name) + (c.isMember ? '' : ' (bot not in channel yet)') + '</option>';
1683
+ }).join('');
1684
+ result.textContent = r.body.channels.length + ' channels found.';
1417
1685
  });
1418
1686
  });
1419
- content.querySelector('#notifierTestBtn').addEventListener('click', function () {
1420
- result.textContent = 'Sending…';
1421
- fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/test', { method: 'POST' }).then(function (r) {
1422
- var t = r.body;
1423
- loadApprovalsSection(name, t.ok ? 'Test delivered (' + (t.status || 'ok') + ').' : 'Test failed: ' + (t.error || 'unknown error'));
1424
- });
1687
+ channelSelect.addEventListener('change', function () {
1688
+ if (channelSelect.value) form.querySelector('input[name="channelId"]').value = channelSelect.value;
1425
1689
  });
1426
- var removeBtn = content.querySelector('#notifierRemoveBtn');
1427
- if (removeBtn) {
1428
- removeBtn.addEventListener('click', function () {
1429
- if (removeBtn.dataset.confirming !== '1') { removeBtn.dataset.confirming = '1'; removeBtn.textContent = 'Click again to remove'; return; }
1430
- fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', { method: 'DELETE' }).then(function () { loadApprovalsSection(name, 'Removed.'); });
1431
- });
1432
- }
1433
1690
  }
1434
1691
 
1435
1692
  function loadApprovalsSection(name, message) {
@@ -1442,7 +1699,13 @@ ${consoleShellScript}
1442
1699
  if (currentName !== name) return;
1443
1700
  if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
1444
1701
  lastNotifier = results[0].body;
1702
+ // Keep open the app sections that were open (a save redraws the tab).
1703
+ var wasOpen = Array.prototype.map.call(content.querySelectorAll('details.msg-conn[open]'), function (d) { return d.dataset.channel; });
1445
1704
  content.innerHTML = renderApprovalsHtml(results[0].body);
1705
+ wasOpen.forEach(function (channel) {
1706
+ var section = content.querySelector('details.msg-conn[data-channel="' + channel + '"]');
1707
+ if (section) section.open = true;
1708
+ });
1446
1709
  wireApprovalsHandlers(name);
1447
1710
  wireApproversHandlers(name, results[0].body);
1448
1711
  var pendingEl = detail.querySelector('#pendingContent');
@@ -1640,9 +1903,16 @@ ${consoleShellScript}
1640
1903
  return detail.querySelector('#actauthContent');
1641
1904
  }
1642
1905
 
1906
+ function advancedRulesEl() {
1907
+ return detail.querySelector('#advancedRulesContent');
1908
+ }
1909
+
1910
+ // Permissions (one switch per tool, and the default) and Advanced's rule
1911
+ // editor (per-rule switches, Edit, Delete, Add a rule).
1643
1912
  function wireActauthHandlers(name, cfg) {
1644
- var content = actauthContentEl();
1645
- if (!content) return;
1913
+ var perm = actauthContentEl();
1914
+ var content = advancedRulesEl();
1915
+ if (!content || !perm) return;
1646
1916
  var editingRuleName = null;
1647
1917
 
1648
1918
  function ruleByName(ruleName) {
@@ -1743,33 +2013,79 @@ ${consoleShellScript}
1743
2013
  });
1744
2014
  });
1745
2015
 
1746
- var defaultForm = content.querySelector('#defaultDecisionForm');
1747
- defaultForm.addEventListener('submit', function (ev) {
1748
- ev.preventDefault();
1749
- var errorEl = content.querySelector('#defaultDecisionError');
1750
- errorEl.textContent = '';
1751
- var decision = content.querySelector('#defaultDecisionSelect').value;
1752
- fetch('/agents/' + encodeURIComponent(name) + '/actauth/default-decision', {
2016
+ // Allow / Ask me / Never: a click saves at once. The pressed button
2017
+ // moves straight away and moves back if the save fails.
2018
+ function wireDecisionSwitch(group, errorEl, save) {
2019
+ group.addEventListener('click', function (ev) {
2020
+ var btn = ev.target.closest('button[data-decision]');
2021
+ if (!btn || btn.getAttribute('aria-pressed') === 'true' || group.classList.contains('is-saving')) return;
2022
+ var buttons = group.querySelectorAll('button[data-decision]');
2023
+ var before = group.querySelector('button[aria-pressed="true"]');
2024
+ function press(target) {
2025
+ for (var k = 0; k < buttons.length; k++) buttons[k].setAttribute('aria-pressed', buttons[k] === target ? 'true' : 'false');
2026
+ }
2027
+ press(btn);
2028
+ group.classList.add('is-saving');
2029
+ errorEl.textContent = '';
2030
+ save(btn.getAttribute('data-decision'))
2031
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
2032
+ .then(function (result) {
2033
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
2034
+ applyActauthConfig(name, result.body);
2035
+ refreshOverviewPanel(name);
2036
+ })
2037
+ .catch(function (err) {
2038
+ press(before);
2039
+ group.classList.remove('is-saving');
2040
+ errorEl.textContent = 'Could not save: ' + err.message;
2041
+ });
2042
+ });
2043
+ }
2044
+
2045
+ var ruleSwitches = content.querySelectorAll('.decision-switch[data-rule]');
2046
+ for (var s = 0; s < ruleSwitches.length; s++) {
2047
+ (function (group) {
2048
+ var rule = ruleByName(group.getAttribute('data-rule'));
2049
+ if (!rule) return;
2050
+ wireDecisionSwitch(group, content.querySelector('#rulesError'), function (decision) {
2051
+ return fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules/' + encodeURIComponent(rule.name), {
2052
+ method: 'PUT',
2053
+ headers: { 'content-type': 'application/json' },
2054
+ body: JSON.stringify({ scope: rule.scope, tool: rule.tool, decision: decision }),
2055
+ });
2056
+ });
2057
+ })(ruleSwitches[s]);
2058
+ }
2059
+
2060
+ var toolSwitches = perm.querySelectorAll('.decision-switch[data-tool]');
2061
+ for (var ts = 0; ts < toolSwitches.length; ts++) {
2062
+ (function (group) {
2063
+ var tool = group.getAttribute('data-tool');
2064
+ wireDecisionSwitch(group, perm.querySelector('#toolsDecisionError'), function (decision) {
2065
+ return fetch('/agents/' + encodeURIComponent(name) + '/actauth/tools/' + encodeURIComponent(tool), {
2066
+ method: 'PUT',
2067
+ headers: { 'content-type': 'application/json' },
2068
+ body: JSON.stringify({ decision: decision }),
2069
+ });
2070
+ });
2071
+ })(toolSwitches[ts]);
2072
+ }
2073
+
2074
+ wireDecisionSwitch(perm.querySelector('#defaultDecisionSwitch'), perm.querySelector('#defaultDecisionError'), function (decision) {
2075
+ return fetch('/agents/' + encodeURIComponent(name) + '/actauth/default-decision', {
1753
2076
  method: 'PUT',
1754
2077
  headers: { 'content-type': 'application/json' },
1755
2078
  body: JSON.stringify({ decision: decision }),
1756
- })
1757
- .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1758
- .then(function (result) {
1759
- if (!result.ok) throw new Error(result.body.error || 'request failed');
1760
- applyActauthConfig(name, result.body);
1761
- refreshOverviewPanel(name);
1762
- })
1763
- .catch(function (err) {
1764
- errorEl.textContent = err.message;
1765
- });
2079
+ });
1766
2080
  });
1767
2081
  }
1768
2082
 
1769
2083
  function applyActauthConfig(name, cfg) {
1770
2084
  var content = actauthContentEl();
1771
- if (!content) return;
1772
- content.innerHTML = renderActauthConfigHtml(cfg);
2085
+ var advanced = advancedRulesEl();
2086
+ if (!content || !advanced) return;
2087
+ content.innerHTML = renderPermissionsHtml(cfg);
2088
+ advanced.innerHTML = renderRulesEditorHtml(cfg);
1773
2089
  wireActauthHandlers(name, cfg);
1774
2090
  actauthLoadedFor = name;
1775
2091
  }
@@ -1778,6 +2094,8 @@ ${consoleShellScript}
1778
2094
  var content = actauthContentEl();
1779
2095
  if (!content) return;
1780
2096
  content.innerHTML = '<p class="hint">Loading&hellip;</p>';
2097
+ var advanced = advancedRulesEl();
2098
+ if (advanced) advanced.innerHTML = '<p class="hint">Loading&hellip;</p>';
1781
2099
  fetch('/agents/' + encodeURIComponent(name) + '/actauth')
1782
2100
  .then(function (r) { return r.json(); })
1783
2101
  .then(function (cfg) {
@@ -2022,46 +2340,53 @@ ${consoleShellScript}
2022
2340
  actionHtml += ' <button type="button" class="ability-install-deps-btn" data-name="' + escapeHtml(a.name) + '" title="npm install ' + escapeHtml(deps.join(' ')) + '">Install deps</button>';
2023
2341
  }
2024
2342
  actionHtml += ' <button type="button" class="ability-uninstall-btn" data-name="' + escapeHtml(a.name) + '">Uninstall</button>';
2343
+ // Details: what it can do, with this agent's own permissions. Fetched
2344
+ // by its install spec, pinned to the installed version for an npm name.
2345
+ var spec = a.spec || a.name;
2346
+ if (!/^(git[+:]|github:|https?:|file:|[.]|[/])/.test(spec) && spec.lastIndexOf('@') <= 0 && a.version) spec += '@' + a.version;
2347
+ var detailsHtml = '<button type="button" class="ability-details-btn" data-spec="' + escapeHtml(spec) + '" data-agent="' + escapeHtml(currentName) + '">Details</button>';
2025
2348
  return '<tr>' +
2026
2349
  '<td><code>' + escapeHtml(a.name) + '</code></td>' +
2027
2350
  '<td>' + escapeHtml(a.version) + '</td>' +
2351
+ '<td>' + leShell.abilityOriginBadge(a.origin) + '</td>' +
2028
2352
  '<td class="hint">' + escapeHtml((a.tools || []).join(', ')) + '</td>' +
2029
- '<td>' + actionHtml + '</td>' +
2353
+ '<td style="white-space:nowrap">' + detailsHtml + ' ' + actionHtml + '</td>' +
2030
2354
  '</tr>';
2031
2355
  }
2032
2356
 
2033
- function renderAbilitySearchResultRow(pkg, installedNames) {
2357
+ // A public (npm) or private ability not installed here yet: its
2358
+ // details show the permissions it ships with.
2359
+ function renderAbilitySearchResultRow(pkg, installedNames, spec) {
2360
+ var installSpec = spec || pkg.name;
2034
2361
  var actionHtml = installedNames.indexOf(pkg.name) !== -1
2035
2362
  ? '<span class="hint">Installed</span>'
2036
- : '<button type="button" class="ability-install-btn" data-spec="' + escapeHtml(pkg.name) + '">Install</button>';
2363
+ : '<button type="button" class="ability-install-btn" data-spec="' + escapeHtml(installSpec) + '">Install</button>';
2364
+ var detailsSpec = spec || (pkg.version ? pkg.name + '@' + pkg.version : pkg.name);
2037
2365
  return '<tr>' +
2038
- '<td><code>' + escapeHtml(pkg.name) + '</code> <span class="hint">' + escapeHtml(pkg.version) + '</span></td>' +
2366
+ '<td><code>' + escapeHtml(pkg.name) + '</code>' + (pkg.version ? ' <span class="hint">' + escapeHtml(pkg.version) + '</span>' : '') + '</td>' +
2039
2367
  '<td>' + escapeHtml(pkg.description || '') + '</td>' +
2040
- '<td>' + actionHtml + '</td>' +
2368
+ '<td style="white-space:nowrap"><button type="button" class="ability-details-btn" data-spec="' + escapeHtml(detailsSpec) + '">Details</button> ' + actionHtml + '</td>' +
2041
2369
  '</tr>';
2042
2370
  }
2043
2371
 
2044
2372
  function renderAbilitiesConfigHtml(data) {
2045
2373
  var installed = data.abilities || [];
2046
2374
  var installedHtml = installed.length
2047
- ? '<table><thead><tr><th>Name</th><th>Version</th><th>Tools</th><th></th></tr></thead><tbody>' + installed.map(renderInstalledAbilityRow).join('') + '</tbody></table>' +
2375
+ ? '<table><thead><tr><th>Name</th><th>Version</th><th>Source</th><th>Tools</th><th></th></tr></thead><tbody>' + installed.map(renderInstalledAbilityRow).join('') + '</tbody></table>' +
2048
2376
  '<p id="abilityUpgradeStatus" class="hint"></p>'
2049
2377
  : '<p class="hint">No abilities installed for this agent yet.</p>';
2050
2378
 
2051
2379
  return '<h3>Installed</h3>' + installedHtml +
2052
- '<h3 style="margin-top:24px">Search public abilities</h3>' +
2053
- '<form id="abilitySearchForm" class="add-source">' +
2054
- '<input type="text" name="q" placeholder="Search by name or keyword (optional)">' +
2380
+ // Installing by name or spec is on the console's Abilities page.
2381
+ '<p id="abilityInstallStatus" class="hint"></p>' +
2382
+ '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Public abilities <span class="hint" style="font-weight:400">npm packages tagged loopengine-ability</span></h3>' +
2383
+ '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
2384
+ '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
2055
2385
  '<button type="submit">Search</button>' +
2056
2386
  '</form>' +
2057
2387
  '<div id="abilitySearchResults" style="margin-bottom:32px"></div>' +
2058
- '<h3 style="margin-top:24px">Install from a spec</h3>' +
2059
- '<p class="hint">A git URL, a local <code>file:../path</code>, or a private npm package name/version — for anything not published publicly.</p>' +
2060
- '<form id="abilityInstallForm" class="add-source">' +
2061
- '<input type="text" name="spec" placeholder="e.g. git+https://github.com/org/repo.git or file:../my-ability" required>' +
2062
- '<button type="submit">Install</button>' +
2063
- '</form>' +
2064
- '<p id="abilityInstallStatus" class="hint"></p>';
2388
+ '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Private abilities <span class="hint" style="font-weight:400">your own, installed in this workspace</span></h3>' +
2389
+ '<div id="abilityPrivateResults" style="margin-bottom:32px"><p class="hint">Loading&hellip;</p></div>';
2065
2390
  }
2066
2391
 
2067
2392
  function abilitiesContentEl() {
@@ -2230,6 +2555,34 @@ ${consoleShellScript}
2230
2555
  });
2231
2556
  }
2232
2557
 
2558
+ // The workspace's private abilities (installed from the console's
2559
+ // Abilities page, core/workspace-abilities.ts), each with Details and
2560
+ // Install into this agent. With none, a link to install some there.
2561
+ function loadPrivateAbilitiesFor(name, content, installedNames) {
2562
+ var el = content.querySelector('#abilityPrivateResults');
2563
+ if (!el) return;
2564
+ fetch('/abilities/workspace')
2565
+ .then(function (r) { return r.json(); })
2566
+ .then(function (d) {
2567
+ if (currentName !== name) return;
2568
+ var abilities = d.abilities || [];
2569
+ if (!abilities.length) {
2570
+ el.innerHTML = '<p><a href="/config?section=abilities#private-abilities">Install private abilities</a></p>';
2571
+ return;
2572
+ }
2573
+ el.innerHTML = '<table><thead><tr><th>Ability</th><th>What it does</th><th></th></tr></thead><tbody>' +
2574
+ abilities.map(function (a) { return renderAbilitySearchResultRow(a, installedNames, a.spec); }).join('') + '</tbody></table>' +
2575
+ '<p class="hint" style="margin:6px 0 0"><a href="/config?section=abilities#private-abilities">Install more private abilities</a></p>';
2576
+ var installBtns = el.querySelectorAll('.ability-install-btn');
2577
+ for (var i = 0; i < installBtns.length; i++) {
2578
+ installBtns[i].addEventListener('click', function (ev) {
2579
+ installAbilitySpec(name, ev.currentTarget.dataset.spec, content.querySelector('#abilityInstallStatus'), ev.currentTarget);
2580
+ });
2581
+ }
2582
+ })
2583
+ .catch(function (err) { el.innerHTML = '<p class="error">Could not list private abilities: ' + escapeHtml(err.message) + '</p>'; });
2584
+ }
2585
+
2233
2586
  // Shared by the initial (empty-query) load right after the tab opens
2234
2587
  // and the search form's own submit — the empty-query case is just
2235
2588
  // "list every published ability" (the keyword filter alone, no extra
@@ -2262,6 +2615,16 @@ ${consoleShellScript}
2262
2615
  var content = abilitiesContentEl();
2263
2616
  if (!content) return;
2264
2617
 
2618
+ // Details buttons anywhere in the tab, including search results that
2619
+ // arrive later. The tab's container outlives each redraw, so wired once.
2620
+ if (!content.dataset.detailsWired) {
2621
+ content.dataset.detailsWired = '1';
2622
+ content.addEventListener('click', function (ev) {
2623
+ var btn = ev.target.closest && ev.target.closest('.ability-details-btn');
2624
+ if (btn) leShell.toggleAbilityDetails(btn);
2625
+ });
2626
+ }
2627
+
2265
2628
  var upgradeStatusEl = content.querySelector('#abilityUpgradeStatus');
2266
2629
  var upgradeBtns = content.querySelectorAll('.ability-upgrade-btn');
2267
2630
  for (var u = 0; u < upgradeBtns.length; u++) {
@@ -2301,17 +2664,7 @@ ${consoleShellScript}
2301
2664
  // operator explicitly searches for something.
2302
2665
  runAbilitySearch(name, '', content, resultsEl, installedNames);
2303
2666
  }
2304
-
2305
- var installForm = content.querySelector('#abilityInstallForm');
2306
- if (installForm) {
2307
- installForm.addEventListener('submit', function (ev) {
2308
- ev.preventDefault();
2309
- var spec = new FormData(installForm).get('spec');
2310
- var statusEl = content.querySelector('#abilityInstallStatus');
2311
- var btn = installForm.querySelector('button[type="submit"]');
2312
- installAbilitySpec(name, spec, statusEl, btn);
2313
- });
2314
- }
2667
+ loadPrivateAbilitiesFor(name, content, installedNames);
2315
2668
  }
2316
2669
 
2317
2670
  function applyAbilitiesConfig(name, data) {
@@ -3114,10 +3467,18 @@ ${consoleShellScript}
3114
3467
 
3115
3468
  // ---- Tab switching + agent selection ----
3116
3469
 
3117
- var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'messaging', 'schedules', 'env'];
3470
+ var CONFIG_TABS = ['overview', 'abilities', 'actauth', 'messaging', 'schedules', 'env', 'advanced'];
3471
+ // The old Tools and Skills tabs: now parts of Advanced. A link to one
3472
+ // opens Advanced at that part.
3473
+ var TAB_ALIASES = { tools: 'advanced', skills: 'advanced' };
3474
+
3475
+ function resolveTab(tab) {
3476
+ tab = TAB_ALIASES[tab] || tab;
3477
+ return CONFIG_TABS.indexOf(tab) === -1 ? 'overview' : tab;
3478
+ }
3118
3479
 
3119
- function switchTab(tab) {
3120
- if (CONFIG_TABS.indexOf(tab) === -1) tab = 'overview';
3480
+ function switchTab(requested) {
3481
+ var tab = resolveTab(requested);
3121
3482
  currentTab = tab;
3122
3483
  leShell.setTab(tab);
3123
3484
  // Same URL a fresh load of this tab would have, so reload, back/forward
@@ -3129,10 +3490,15 @@ ${consoleShellScript}
3129
3490
  for (var i = 0; i < panels.length; i++) {
3130
3491
  panels[i].style.display = panels[i].dataset.tabPanel === tab ? 'block' : 'none';
3131
3492
  }
3132
- if (tab === 'tools' && gatewayLoadedFor !== currentName) {
3493
+ if (tab === 'advanced' && gatewayLoadedFor !== currentName) {
3133
3494
  loadGatewayTab(currentName);
3134
3495
  }
3135
- if (tab === 'actauth' && actauthLoadedFor !== currentName) {
3496
+ // Opened through an old Tools or Skills link: scroll to that part.
3497
+ if (TAB_ALIASES[requested]) {
3498
+ var part = detail.querySelector('#advanced-' + requested);
3499
+ if (part) part.scrollIntoView({ block: 'start' });
3500
+ }
3501
+ if ((tab === 'actauth' || tab === 'advanced') && actauthLoadedFor !== currentName) {
3136
3502
  loadActauthTab(currentName);
3137
3503
  }
3138
3504
  if (tab === 'messaging') loadApprovalsSection(currentName);
@@ -3237,8 +3603,18 @@ ${consoleShellScript}
3237
3603
  }) +
3238
3604
  '<div class="le-scroll"><div class="le-page" style="display:block">' +
3239
3605
  '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
3240
- '<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
3241
- '<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
3606
+ // Advanced: the agent's own tools (and Composio, subagents), then its
3607
+ // own skills. Each part keeps its old data-tab-panel name, which the
3608
+ // code that redraws it looks for; only .tab-panel is shown or hidden.
3609
+ '<div class="tab-panel" data-tab-panel="advanced">' +
3610
+ '<p class="muted" style="margin:0 0 20px">For building your own: tools and skills that don&#39;t come from an ability, Composio tools, subagents and permission rules in full. What each ability adds is in its Details on the Abilities tab.</p>' +
3611
+ '<h2 class="advanced-part" id="advanced-tools">Tools</h2>' +
3612
+ '<div data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
3613
+ '<h2 class="advanced-part" id="advanced-skills">Skills</h2>' +
3614
+ '<div data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
3615
+ '<h2 class="advanced-part" id="advanced-rules">Permission rules</h2>' +
3616
+ '<div id="advancedRulesContent"><p class="hint">Loading&hellip;</p></div>' +
3617
+ '</div>' +
3242
3618
  '<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
3243
3619
  '<div class="tab-panel" data-tab-panel="messaging">' + renderMessagingTabPlaceholder() + '</div>' +
3244
3620
  '<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
@@ -3256,8 +3632,10 @@ ${consoleShellScript}
3256
3632
 
3257
3633
  empty.style.display = 'none';
3258
3634
  detail.style.display = 'flex';
3259
- switchTab(currentTab);
3635
+ switchTab(initialAdvancedPart || currentTab);
3636
+ initialAdvancedPart = null;
3260
3637
  fillOverviewCards(cfg.name);
3638
+ loadWorkflowsSection(cfg.name);
3261
3639
  }
3262
3640
 
3263
3641
  function selectAgent(name) {
@@ -3301,7 +3679,9 @@ ${consoleShellScript}
3301
3679
  });
3302
3680
 
3303
3681
  var initialParams = new URLSearchParams(location.search);
3304
- currentTab = CONFIG_TABS.indexOf(initialParams.get('tab')) === -1 ? 'overview' : initialParams.get('tab');
3682
+ currentTab = resolveTab(initialParams.get('tab'));
3683
+ // An old ?tab=tools or ?tab=skills link: scrolled to that part once loaded.
3684
+ var initialAdvancedPart = TAB_ALIASES[initialParams.get('tab')] ? initialParams.get('tab') : null;
3305
3685
  leShell.ready.then(function (agents) {
3306
3686
  if (!agents.length) {
3307
3687
  empty.innerHTML = leShell.loadError()