loopengine 0.2.2 → 0.3.1

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.
@@ -49,8 +49,20 @@ export const agentsConfigPageHtml = `<!doctype html>
49
49
  form.add-source .schedule-when input[type="number"] { width: 80px; }
50
50
  form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
51
51
  .workflow-card { margin-bottom: 10px; }
52
- .workflow-steps { margin: 0; padding-left: 20px; font-size: 13px; }
53
- .workflow-steps li { margin: 3px 0; }
52
+ .workflow-steps-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; margin: 6px 0 0; padding: 8px 10px; border-radius: 6px; background: var(--le-surface, #f6f7f9); }
53
+ .workflow-params { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
54
+ .workflow-params[hidden] { display: none; }
55
+ .workflow-editor[hidden] { display: none; }
56
+ .workflow-hooks[hidden] { display: none; }
57
+ .hook-custom[hidden] { display: none; }
58
+ .workflow-hook { border-top: 1px solid var(--le-line, #e5e7eb); padding: 8px 0; }
59
+ .hook-url { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; margin: 6px 0; border-radius: 6px; background: var(--le-surface, #f6f7f9); font-size: 13px; }
60
+ .hook-url input { font-family: var(--le-mono, monospace); font-size: 12.5px; }
61
+ .workflow-param-rows { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; }
62
+ .workflow-param-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
63
+ .workflow-param-row .wp-name { width: 160px; font-family: var(--le-mono, monospace); }
64
+ .workflow-param-row .wp-desc { flex: 1; min-width: 200px; }
65
+ .workflow-param { display: flex; flex-direction: column; gap: 3px; font-size: 12px; min-width: 180px; }
54
66
  .advanced-part { font-size: 17px; font-weight: 600; margin: 28px 0 12px; padding-top: 16px; border-top: 1px solid var(--line); }
55
67
  .advanced-part#advanced-tools { margin-top: 0; padding-top: 0; border-top: 0; }
56
68
  .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; }
@@ -192,6 +204,9 @@ export const agentsConfigPageHtml = `<!doctype html>
192
204
  max-width: 480px;
193
205
  }
194
206
  form.add-source label { font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
207
+ form.add-source label[hidden] { display: none; }
208
+ .schedule-notify { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; cursor: pointer; }
209
+ .schedule-notify input { width: auto; margin: 0; }
195
210
  form.add-source input, form.add-source select, form.add-source textarea { width: 100%; }
196
211
  form.add-source textarea { font-family: ui-monospace, monospace; min-height: 60px; }
197
212
  /* Overrides form.add-source's shared max-width/textarea defaults for
@@ -678,7 +693,7 @@ ${consoleShellScript}
678
693
  return workflows.map(function (w) {
679
694
  var s = w.schedule;
680
695
  var state = !s
681
- ? (w.trigger.kind === 'schedule' ? '<span class="hint">no schedule found</span>' : '<span class="hint">started when asked</span>')
696
+ ? '<span class="hint">' + escapeHtml(w.trigger) + '</span>'
682
697
  : s.waiting
683
698
  ? '<span class="le-pill ask">Waiting for a decision</span>'
684
699
  : s.status === 'paused'
@@ -686,53 +701,337 @@ ${consoleShellScript}
686
701
  : s.status === 'running'
687
702
  ? '<span class="le-pill accent">Running</span>'
688
703
  : '<span class="le-pill good">On</span>' + (s.nextRunAt ? ' <span class="hint">next ' + escapeHtml(new Date(s.nextRunAt).toLocaleString()) + '</span>' : '');
689
- var steps = (w.steps || []).map(function (st) {
690
- var mark = st.kind === 'ask' ? ' <span class="le-pill accent">Asks you</span>' : st.kind === 'approval' ? ' <span class="le-pill ask">You approve</span>' : '';
691
- return '<li>' + escapeHtml(st.title) + (st.tool && st.tool !== 'system_ask_user' ? ' <code>' + escapeHtml(st.tool) + '</code>' : '') + mark + '</li>';
704
+ var paramNames = Object.keys(w.parameters || {});
705
+ var params = paramNames.map(function (p) {
706
+ var spec = w.parameters[p];
707
+ return '<label class="workflow-param">' + escapeHtml(p) + (spec.required ? '' : ' <span class="hint">(optional)</span>') +
708
+ '<input name="' + escapeHtml(p) + '"' + (spec.required ? ' required' : '') + ' placeholder="' + escapeHtml(spec.description || '') + '"></label>';
709
+ }).join('');
710
+ var scheduleCount = (w.schedules || []).filter(function (x) { return x.repeating; }).length;
711
+ var runs = (w.recentRuns || []).map(function (r) {
712
+ var pill = r.status === 'done' ? '<span class="le-pill good">Done</span>'
713
+ : r.status === 'error' ? '<span class="le-pill bad">Failed</span>'
714
+ : r.status === 'awaiting_approval' ? '<span class="le-pill ask">Waiting for a decision</span>'
715
+ : '<span class="le-pill accent">Running</span>';
716
+ var values = r.params ? Object.keys(r.params).map(function (k) { return escapeHtml(k) + ': ' + escapeHtml(r.params[k]); }).join(' ' + String.fromCharCode(183) + ' ') : '';
717
+ var outcome = r.status === 'error' ? (r.error || '') : (r.result || '');
718
+ return '<li>' + pill + ' <span class="hint">' + escapeHtml(new Date(r.started_at).toLocaleString()) + (r.source === 'api' ? ' ' + String.fromCharCode(183) + ' from an app' : r.source === 'schedule' ? ' ' + String.fromCharCode(183) + ' scheduled' + (r.task_name ? ' (' + escapeHtml(r.task_name) + ')' : '') : '') + '</span>' +
719
+ (values ? '<div class="hint">' + values + '</div>' : '') +
720
+ (outcome ? '<div class="schedule-result">' + escapeHtml(outcome) + '</div>' : '') + '</li>';
692
721
  }).join('');
693
722
  return '<div class="source workflow-card" data-workflow="' + escapeHtml(w.id) + '">' +
694
723
  '<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:baseline">' +
695
- '<strong>' + escapeHtml(w.name) + '</strong><span>' + state + '</span></div>' +
696
- '<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.summary) + ' ' + String.fromCharCode(183) + ' ' + escapeHtml(w.trigger.description) + '</p>' +
697
- '<ol class="workflow-steps">' + steps + '</ol>' +
724
+ '<strong>' + escapeHtml(w.title) + '</strong><span>' + state + '</span></div>' +
725
+ '<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.description) + (s ? ' ' + String.fromCharCode(183) + ' ' + escapeHtml(w.trigger) : '') + '</p>' +
726
+ '<details><summary class="hint">Steps <span>(the ' + escapeHtml(w.id) + ' skill)</span></summary><div class="workflow-steps-text">' + escapeHtml(w.steps) + '</div></details>' +
727
+ (params ? '<div class="workflow-params">' + params + '</div>' : '') +
728
+ (runs ? '<details class="workflow-runs"' + ((w.recentRuns || []).some(function (r) { return r.status === 'running' || r.status === 'awaiting_approval'; }) ? ' open' : '') + '><summary class="hint">Recent runs (' + w.recentRuns.length + ')</summary><ul class="schedule-history">' + runs + '</ul></details>' : '') +
698
729
  '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">' +
699
730
  '<button type="button" class="workflow-run">Run now</button>' +
700
- (s ? '<a href="?tab=schedules" data-tab="schedules" class="hint" style="align-self:center">Schedule</a>' : '') +
731
+ (scheduleCount ? '<a href="?tab=schedules" data-tab="schedules" class="hint" style="align-self:center">' + (scheduleCount > 1 ? 'Schedules (' + scheduleCount + ')' : 'Schedule') + '</a>' : '') +
732
+ '<button type="button" class="workflow-edit">Edit</button>' +
733
+ '<button type="button" class="workflow-hooks-btn">Webhooks</button>' +
734
+ '<a href="?tab=skills" data-tab="skills" class="hint" style="align-self:center">Edit steps</a>' +
701
735
  '<button type="button" class="delete-btn workflow-forget">Forget</button>' +
702
- '</div><div class="hint workflow-result"></div></div>';
736
+ '</div><div class="hint workflow-result"></div><div class="workflow-editor" hidden></div><div class="workflow-hooks" hidden></div></div>';
703
737
  }).join('') + '<p class="hint" style="margin:6px 0 0"><a href="/workflows/new">Describe another workflow</a></p>';
704
738
  }
705
739
 
740
+ var workflowsRefreshTimer = null;
741
+ var workflowsById = {};
742
+ // Set while a workflow's editor is open, so the running-run refresh
743
+ // doesn't redraw the card under someone typing.
744
+ var workflowEditing = false;
745
+
706
746
  function loadWorkflowsSection(name) {
707
747
  var el = detail.querySelector('#workflowsContent');
708
748
  if (!el) return;
749
+ if (workflowsRefreshTimer) { clearTimeout(workflowsRefreshTimer); workflowsRefreshTimer = null; }
709
750
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/workflows').then(function (r) {
710
751
  if (currentName !== name) return;
711
752
  if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
712
- el.innerHTML = renderWorkflowsHtml(name, r.body.workflows || []);
753
+ var workflows = r.body.workflows || [];
754
+ workflowsById = {};
755
+ workflows.forEach(function (w) { workflowsById[w.id] = w; });
756
+ el.innerHTML = renderWorkflowsHtml(name, workflows);
713
757
  var cards = el.querySelectorAll('.workflow-card');
714
758
  for (var i = 0; i < cards.length; i++) wireWorkflowCard(name, cards[i]);
759
+ // Keep a running run's status current while the Overview is open.
760
+ var running = workflows.some(function (w) { return (w.recentRuns || []).some(function (x) { return x.status === 'running'; }); });
761
+ if (running && currentTab === 'overview') workflowsRefreshTimer = setTimeout(function () { if (!workflowEditing) loadWorkflowsSection(name); }, 3000);
762
+ });
763
+ }
764
+
765
+ // ---- The workflow editor: how a workflow's runs start. Its steps are
766
+ // the skill body, edited on the Skills tab.
767
+ function workflowParamRowHtml(pname, spec) {
768
+ return '<div class="workflow-param-row">' +
769
+ '<input class="wp-name" aria-label="Parameter name" placeholder="order_number" value="' + escapeHtml(pname || '') + '">' +
770
+ '<input class="wp-desc" aria-label="What it is" placeholder="What it is, e.g. The order to check" value="' + escapeHtml((spec && spec.description) || '') + '">' +
771
+ '<label class="http-tool-inline-label"><input type="checkbox" class="wp-required"' + (spec && spec.required ? ' checked' : '') + '> Required</label>' +
772
+ '<button type="button" class="delete-btn wp-remove">Remove</button></div>';
773
+ }
774
+
775
+ function workflowEditorHtml(w) {
776
+ var rows = Object.keys(w.parameters || {}).map(function (p) { return workflowParamRowHtml(p, w.parameters[p]); }).join('');
777
+ return '<form class="add-source workflow-editor-form" style="margin-top:10px">' +
778
+ '<label>Title<input name="title" maxlength="120" value="' + escapeHtml(w.title) + '"></label>' +
779
+ '<label>Start message <span class="hint">(what each run begins with; $name puts in a parameter&#39;s value)</span>' +
780
+ '<textarea name="start_message" maxlength="4000" required>' + escapeHtml(w.startMessage) + '</textarea></label>' +
781
+ '<label>When it runs <span class="hint">(in words, for people; the timing itself is its schedule)</span><input name="trigger" maxlength="200" value="' + escapeHtml(w.trigger) + '"></label>' +
782
+ '<div class="body-field">Parameters <span class="hint">(values a run is given; apps send them as params)</span>' +
783
+ '<div class="workflow-param-rows">' + rows + '</div>' +
784
+ '<button type="button" class="wp-add" style="align-self:flex-start">Add parameter</button></div>' +
785
+ '<p class="hint workflow-editor-check" style="margin:0"></p>' +
786
+ '<div style="display:flex;gap:8px"><button type="submit" class="primary">Save</button><button type="button" class="wp-cancel">Cancel</button></div>' +
787
+ '<div class="hint workflow-editor-result"></div></form>';
788
+ }
789
+
790
+ // Placeholders used in the start message or steps that no parameter
791
+ // declares, and declared parameters nothing uses.
792
+ function workflowPlaceholderCheck(w, form) {
793
+ var used = {};
794
+ var pattern = new RegExp('[$]([a-z_][a-z0-9_]*)', 'g');
795
+ var text = form.querySelector('[name="start_message"]').value + ' ' + (w.steps || '');
796
+ var m;
797
+ while ((m = pattern.exec(text))) used[m[1]] = true;
798
+ var declared = {};
799
+ var names = form.querySelectorAll('.wp-name');
800
+ for (var i = 0; i < names.length; i++) if (names[i].value.trim()) declared[names[i].value.trim()] = true;
801
+ var notes = [];
802
+ var missing = Object.keys(used).filter(function (n) { return !declared[n]; });
803
+ var unused = Object.keys(declared).filter(function (n) { return !used[n]; });
804
+ if (missing.length) notes.push('Uses ' + missing.map(function (n) { return '$' + n; }).join(', ') + ', which ' + (missing.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't a parameter: add ' + (missing.length > 1 ? 'them' : 'it') + ', or the text stays as written.');
805
+ if (unused.length) notes.push(unused.join(', ') + ' ' + (unused.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't used in the start message or steps: its value is passed as an input.');
806
+ form.querySelector('.workflow-editor-check').textContent = notes.join(' ');
807
+ }
808
+
809
+ function openWorkflowEditor(name, card) {
810
+ var w = workflowsById[card.dataset.workflow];
811
+ var box = card.querySelector('.workflow-editor');
812
+ if (!w || !box) return;
813
+ workflowEditing = true;
814
+ box.innerHTML = workflowEditorHtml(w);
815
+ box.hidden = false;
816
+ var form = box.querySelector('form');
817
+ var rows = form.querySelector('.workflow-param-rows');
818
+ var result = form.querySelector('.workflow-editor-result');
819
+ function check() { workflowPlaceholderCheck(w, form); }
820
+ function close() { workflowEditing = false; box.hidden = true; box.innerHTML = ''; }
821
+ rows.addEventListener('click', function (ev) {
822
+ if (ev.target.classList.contains('wp-remove')) { ev.target.closest('.workflow-param-row').remove(); check(); }
823
+ });
824
+ form.addEventListener('input', check);
825
+ form.querySelector('.wp-add').addEventListener('click', function () {
826
+ rows.insertAdjacentHTML('beforeend', workflowParamRowHtml('', null));
827
+ rows.lastElementChild.querySelector('.wp-name').focus();
828
+ });
829
+ form.querySelector('.wp-cancel').addEventListener('click', close);
830
+ form.addEventListener('submit', function (ev) {
831
+ ev.preventDefault();
832
+ var parameters = {};
833
+ var problem = '';
834
+ var list = rows.querySelectorAll('.workflow-param-row');
835
+ for (var i = 0; i < list.length; i++) {
836
+ var pname = list[i].querySelector('.wp-name').value.trim();
837
+ if (!pname) continue;
838
+ if (!/^[a-z_][a-z0-9_]*$/.test(pname)) { problem = 'Parameter "' + pname + '": use lowercase letters, digits and _, starting with a letter, e.g. order_number.'; break; }
839
+ if (parameters[pname]) { problem = 'Parameter "' + pname + '" is listed twice.'; break; }
840
+ parameters[pname] = { description: list[i].querySelector('.wp-desc').value.trim(), required: list[i].querySelector('.wp-required').checked };
841
+ }
842
+ if (problem) { result.textContent = problem; return; }
843
+ result.textContent = 'Saving' + String.fromCharCode(8230);
844
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(w.id), {
845
+ method: 'PUT', headers: { 'content-type': 'application/json' },
846
+ body: JSON.stringify({ title: form.title.value, start_message: form.start_message.value, trigger: form.trigger.value, parameters: parameters }),
847
+ }).then(function (r) {
848
+ if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
849
+ workflowEditing = false;
850
+ loadWorkflowsSection(name);
851
+ });
715
852
  });
853
+ check();
854
+ form.title.focus();
855
+ }
856
+
857
+ // ---- A workflow's webhook triggers (core/workflow-triggers.ts): secret
858
+ // URLs an outside service posts its events to, to start a run.
859
+ var HOOK_OUTCOMES = {
860
+ started: ['good', 'Started a run'], filtered: ['', 'Skipped by the filter'], duplicate: ['', 'A retry, skipped'],
861
+ busy: ['', 'Already running'], rejected: ['bad', 'Signature check failed'], invalid: ['bad', 'Couldn' + String.fromCharCode(8217) + 't start'], error: ['bad', 'Failed'],
862
+ };
863
+ var HOOK_SENDERS = { stripe: 'Stripe', github: 'GitHub', shopify: 'Shopify', standard: 'Standard Webhooks' };
864
+
865
+ function hookRowHtml(t) {
866
+ var last = (t.deliveries || []).slice(0, 3).map(function (d) {
867
+ var o = HOOK_OUTCOMES[d.outcome] || ['', d.outcome];
868
+ return '<li><span class="le-pill ' + o[0] + '">' + escapeHtml(o[1]) + '</span> <span class="hint">' + escapeHtml(new Date(d.at).toLocaleString()) + '</span>' +
869
+ (d.detail ? '<div class="schedule-result">' + escapeHtml(d.detail) + '</div>' : '') + '</li>';
870
+ }).join('');
871
+ var rules = [];
872
+ if (t.verify) rules.push(t.verify.sender === 'custom'
873
+ ? 'checks the signature in ' + escapeHtml(t.verify.header) + ' (' + escapeHtml(t.verify.algorithm || 'sha256') + ', ' + escapeHtml(t.verify.encoding || 'hex') + ')'
874
+ : 'checks the ' + escapeHtml(HOOK_SENDERS[t.verify.sender] || t.verify.sender) + ' signature');
875
+ if (t.filter) rules.push('only when ' + escapeHtml(t.filter.path) + ' is ' + t.filter.values.map(escapeHtml).join(' or '));
876
+ if (t.mapping) rules.push(Object.keys(t.mapping).map(function (p) { return escapeHtml(p) + ' ' + String.fromCharCode(8592) + ' ' + escapeHtml(t.mapping[p]); }).join(', '));
877
+ if (t.payload_param) rules.push('the whole payload goes to ' + escapeHtml(t.payload_param));
878
+ if (t.notify) rules.push('sends each result to you');
879
+ if (t.callback_url) rules.push('reports to ' + escapeHtml(t.callback_url));
880
+ return '<div class="workflow-hook" data-trigger="' + escapeHtml(t.trigger_id) + '">' +
881
+ '<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;align-items:baseline"><strong>' + escapeHtml(t.name) + '</strong>' +
882
+ '<code class="hint">/hooks/workflows/' + escapeHtml(t.token_hint) + '</code>' + (t.enabled ? '' : ' <span class="le-pill">Off</span>') + '</div>' +
883
+ (rules.length ? '<p class="hint" style="margin:2px 0">' + rules.join(' ' + String.fromCharCode(183) + ' ') + '</p>' : '') +
884
+ (last ? '<ul class="schedule-history">' + last + '</ul>' : '<p class="hint" style="margin:2px 0">No deliveries yet.</p>') +
885
+ '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:4px">' +
886
+ '<button type="button" class="hook-toggle">' + (t.enabled ? 'Turn off' : 'Turn on') + '</button>' +
887
+ '<button type="button" class="hook-rotate">New URL</button>' +
888
+ '<button type="button" class="delete-btn hook-delete">Delete</button></div></div>';
889
+ }
890
+
891
+ function hookFormHtml(w) {
892
+ var params = Object.keys(w.parameters || {});
893
+ return '<form class="add-source hook-form" style="margin-top:10px">' +
894
+ '<h4 style="margin:0">Add a webhook</h4>' +
895
+ '<label>Name<input name="name" maxlength="80" placeholder="Stripe disputes"></label>' +
896
+ '<label>Check the sender' + String.fromCharCode(8217) + 's signature <span class="hint">(the URL itself is secret; this also proves who sent it)</span>' +
897
+ '<select name="sender"><option value="">No, the URL is enough</option><option value="stripe">Stripe</option><option value="github">GitHub</option><option value="shopify">Shopify</option>' +
898
+ '<option value="standard">Standard Webhooks (Clerk, Resend, OpenAI, Svix)</option><option value="custom">Another service: set it up myself</option></select></label>' +
899
+ '<div class="hook-custom body-field" hidden>How it signs <span class="hint">(from its webhook docs: an HMAC of the request body, in a header)</span>' +
900
+ '<div class="workflow-param-row">' +
901
+ '<input name="sig_header" placeholder="Header, e.g. Linear-Signature" style="flex:1">' +
902
+ '<select name="sig_algorithm" aria-label="Hash"><option value="sha256">SHA-256</option><option value="sha1">SHA-1</option><option value="sha512">SHA-512</option></select>' +
903
+ '<select name="sig_encoding" aria-label="Written as"><option value="hex">hex</option><option value="base64">base64</option></select>' +
904
+ '<input name="sig_prefix" placeholder="Prefix, e.g. sha256= (if any)" style="width:180px">' +
905
+ '</div></div>' +
906
+ '<label class="hook-secret" hidden>Signing secret <span class="hint">(from the sender' + String.fromCharCode(8217) + 's webhook settings)</span><input name="secret" autocomplete="off"></label>' +
907
+ '<label>Run only when <span class="hint">(optional: a field in the payload, and the values that start a run)</span>' +
908
+ '<div class="workflow-param-row"><input name="filter_path" placeholder="type"><input name="filter_values" placeholder="charge.dispute.created, charge.dispute.updated" style="flex:1"></div></label>' +
909
+ (params.length
910
+ ? '<div class="body-field">Parameters from the payload <span class="hint">(a dot path into its JSON, e.g. data.object.id)</span>' +
911
+ params.map(function (p) { return '<div class="workflow-param-row"><code class="wp-name">' + escapeHtml(p) + '</code><input name="map:' + escapeHtml(p) + '" placeholder="data.object.id" style="flex:1"></div>'; }).join('') + '</div>' +
912
+ '<label>Or give the whole payload to <select name="payload_param"><option value="">(none)</option>' + params.map(function (p) { return '<option>' + escapeHtml(p) + '</option>'; }).join('') + '</select></label>'
913
+ : '<p class="hint" style="margin:0">This workflow takes no parameters, so the payload isn' + String.fromCharCode(8217) + 't passed on. Add one in Edit to use it.</p>') +
914
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each run' + String.fromCharCode(8217) + 's result to you through the agent' + String.fromCharCode(8217) + 's messaging app</label>' +
915
+ '<label>Post each run' + String.fromCharCode(8217) + 's result to <span class="hint">(optional, an https URL)</span><input name="callback_url" placeholder="https://example.com/hooks/loopengine"></label>' +
916
+ '<div style="display:flex;gap:8px"><button type="submit" class="primary">Make the URL</button></div>' +
917
+ '<div class="hint hook-form-result"></div></form>';
918
+ }
919
+
920
+ function hookUrlHtml(url) {
921
+ return '<div class="hook-url"><strong>Copy this URL now</strong> <span class="hint">(it' + String.fromCharCode(8217) + 's shown only once; make a new one if it' + String.fromCharCode(8217) + 's lost)</span>' +
922
+ '<input readonly value="' + escapeHtml(url) + '" onclick="this.select()"></div>';
923
+ }
924
+
925
+ function openWorkflowHooks(name, card) {
926
+ var w = workflowsById[card.dataset.workflow];
927
+ var box = card.querySelector('.workflow-hooks');
928
+ if (!w || !box) return;
929
+ if (!box.hidden) { box.hidden = true; box.innerHTML = ''; workflowEditing = false; return; }
930
+ workflowEditing = true;
931
+ box.hidden = false;
932
+ var base = '/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(w.id) + '/triggers';
933
+ function render(shownUrl) {
934
+ box.innerHTML = '<p class="hint" style="margin:0">Loading' + String.fromCharCode(8230) + '</p>';
935
+ fetchJsonRetryOnce(base).then(function (r) {
936
+ if (!r.ok) { box.innerHTML = '<p class="error">' + escapeHtml(r.body.error || 'Could not load webhooks') + '</p>'; return; }
937
+ var list = r.body.triggers || [];
938
+ box.innerHTML = '<h4 style="margin:10px 0 4px">Webhooks</h4>' +
939
+ '<p class="hint" style="margin:0 0 6px">A secret URL an outside service (Stripe, GitHub, a form, monitoring) posts its events to; each one starts a run.</p>' +
940
+ (shownUrl ? hookUrlHtml(shownUrl) : '') +
941
+ (list.length ? list.map(hookRowHtml).join('') : '') + hookFormHtml(w);
942
+ wire();
943
+ });
944
+ }
945
+ function act(id, path, opts) {
946
+ return fetchJsonRetryOnce(base + '/' + encodeURIComponent(id) + path, opts).then(function (r) {
947
+ if (!r.ok) { alert(r.body.error || 'That didn' + String.fromCharCode(8217) + 't work'); return; }
948
+ render(r.body.url);
949
+ });
950
+ }
951
+ function wire() {
952
+ var rows = box.querySelectorAll('.workflow-hook');
953
+ for (var i = 0; i < rows.length; i++) {
954
+ (function (row) {
955
+ var id = row.dataset.trigger;
956
+ row.querySelector('.hook-toggle').addEventListener('click', function (ev) {
957
+ act(id, '', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled: ev.currentTarget.textContent === 'Turn on' }) });
958
+ });
959
+ row.querySelector('.hook-rotate').addEventListener('click', function (ev) {
960
+ var btn = ev.currentTarget;
961
+ if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again: the old URL stops working'; return; }
962
+ act(id, '/rotate', { method: 'POST' });
963
+ });
964
+ row.querySelector('.hook-delete').addEventListener('click', function (ev) {
965
+ var btn = ev.currentTarget;
966
+ if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to delete'; return; }
967
+ act(id, '', { method: 'DELETE' });
968
+ });
969
+ })(rows[i]);
970
+ }
971
+ var form = box.querySelector('.hook-form');
972
+ var secretField = form.querySelector('.hook-secret');
973
+ var customField = form.querySelector('.hook-custom');
974
+ form.sender.addEventListener('change', function () {
975
+ secretField.hidden = !form.sender.value;
976
+ customField.hidden = form.sender.value !== 'custom';
977
+ });
978
+ form.addEventListener('submit', function (ev) {
979
+ ev.preventDefault();
980
+ var result = form.querySelector('.hook-form-result');
981
+ var body = { name: form.name.value };
982
+ if (form.sender.value) body.verify = { sender: form.sender.value, secret: form.secret.value };
983
+ if (form.sender.value === 'custom') {
984
+ body.verify.header = form.sig_header.value.trim();
985
+ body.verify.algorithm = form.sig_algorithm.value;
986
+ body.verify.encoding = form.sig_encoding.value;
987
+ if (form.sig_prefix.value.trim()) body.verify.prefix = form.sig_prefix.value.trim();
988
+ }
989
+ var values = form.filter_values.value.split(',').map(function (v) { return v.trim(); }).filter(Boolean);
990
+ if (form.filter_path.value.trim() || values.length) body.filter = { path: form.filter_path.value.trim(), values: values };
991
+ var mapping = {};
992
+ var inputs = form.querySelectorAll('input[name^="map:"]');
993
+ for (var j = 0; j < inputs.length; j++) if (inputs[j].value.trim()) mapping[inputs[j].name.slice(4)] = inputs[j].value.trim();
994
+ if (Object.keys(mapping).length) body.mapping = mapping;
995
+ if (form.payload_param && form.payload_param.value) body.payload_param = form.payload_param.value;
996
+ if (form.notify.checked) body.notify = true;
997
+ if (form.callback_url.value.trim()) body.callback_url = form.callback_url.value.trim();
998
+ result.textContent = 'Making it' + String.fromCharCode(8230);
999
+ fetchJsonRetryOnce(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) {
1000
+ if (!r.ok) { result.textContent = r.body.error || 'Could not make it'; return; }
1001
+ render(r.body.url);
1002
+ });
1003
+ });
1004
+ }
1005
+ render(null);
716
1006
  }
717
1007
 
718
1008
  function wireWorkflowCard(name, card) {
719
1009
  var id = card.dataset.workflow;
720
1010
  var result = card.querySelector('.workflow-result');
721
1011
  var base = '/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(id);
1012
+ card.querySelector('.workflow-edit').addEventListener('click', function () { openWorkflowEditor(name, card); });
1013
+ card.querySelector('.workflow-hooks-btn').addEventListener('click', function () { openWorkflowHooks(name, card); });
722
1014
  card.querySelector('.workflow-run').addEventListener('click', function (ev) {
723
1015
  var btn = ev.currentTarget;
1016
+ var params = {};
1017
+ var inputs = card.querySelectorAll('.workflow-params input');
1018
+ for (var i = 0; i < inputs.length; i++) {
1019
+ var value = inputs[i].value.trim();
1020
+ if (!value && inputs[i].required) { result.textContent = 'Fill in ' + inputs[i].name + ' first.'; inputs[i].focus(); return; }
1021
+ if (value) params[inputs[i].name] = value;
1022
+ }
724
1023
  btn.disabled = true;
725
1024
  result.textContent = 'Starting' + String.fromCharCode(8230);
726
- fetchJsonRetryOnce(base + '/run', { method: 'POST' }).then(function (r) {
1025
+ fetchJsonRetryOnce(base + '/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ params: params, source: 'console' }) }).then(function (r) {
727
1026
  btn.disabled = false;
728
- result.textContent = r.ok
729
- ? 'Started. Questions and approvals go to this agent' + String.fromCharCode(8217) + 's messaging app; its result shows in the Schedules tab.'
730
- : (r.body.error || 'Could not start it.');
1027
+ if (!r.ok) { result.textContent = r.body.error || 'Could not start it.'; return; }
1028
+ result.textContent = 'Started. Questions and approvals go to this agent' + String.fromCharCode(8217) + 's messaging app; the result shows under Recent runs.';
1029
+ setTimeout(function () { loadWorkflowsSection(name); }, 800);
731
1030
  });
732
1031
  });
733
1032
  card.querySelector('.workflow-forget').addEventListener('click', function (ev) {
734
1033
  var btn = ev.currentTarget;
735
- if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again (its skill, schedule and rules stay)'; return; }
1034
+ if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again (its schedules are cancelled; the skill stays)'; return; }
736
1035
  fetchJsonRetryOnce(base, { method: 'DELETE' }).then(function () { loadWorkflowsSection(name); });
737
1036
  });
738
1037
  }
@@ -1775,6 +2074,9 @@ ${consoleShellScript}
1775
2074
  // The workspace's time zone, from GET /agents/:name/schedules (an
1776
2075
  // older project adapter doesn't send it: then the browser's own).
1777
2076
  var schedulesTimeZone = null;
2077
+ // The agent's workflows, from the same response: a new schedule can run
2078
+ // one of them instead of a message.
2079
+ var schedulesWorkflows = [];
1778
2080
 
1779
2081
  function fmtScheduleTime(iso) {
1780
2082
  var d = new Date(iso);
@@ -1807,8 +2109,16 @@ ${consoleShellScript}
1807
2109
  '<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
1808
2110
  }).join('');
1809
2111
  return '<tr>' +
1810
- '<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' + (t.notify ? ' <span class="le-pill accent" title="Each result goes through this agent&#39;s notifier">notifies</span>' : '') +
1811
- '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
2112
+ '<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' +
2113
+ (t.workflow
2114
+ ? '<div><span class="le-pill accent">Workflow</span> ' + escapeHtml(t.workflow.title) + '</div><div class="schedule-result hint">' + escapeHtml(t.workflow.start_message) +
2115
+ (t.params ? '<br>' + Object.keys(t.params).map(function (k) { return escapeHtml(k) + ': ' + escapeHtml(t.params[k]); }).join(' ' + String.fromCharCode(183) + ' ') : '') + '</div>'
2116
+ : t.workflow_missing
2117
+ ? '<div><span class="le-pill bad">Workflow removed</span> <span class="hint">' + escapeHtml(t.workflow_id) + '</span></div>'
2118
+ : '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div>') +
2119
+ (t.status !== 'cancelled' && t.status !== 'done'
2120
+ ? '<label class="schedule-notify hint" title="Send each run&#39;s result through this agent&#39;s messaging connection (Messaging tab)"><input type="checkbox" class="schedule-notify-toggle" data-id="' + escapeHtml(t.task_id) + '"' + (t.notify ? ' checked' : '') + '> Send each result to you</label>'
2121
+ : (t.notify ? '<div class="hint">Sent each result to you</div>' : '')) + '</td>' +
1812
2122
  '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
1813
2123
  '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
1814
2124
  (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
@@ -1823,11 +2133,22 @@ ${consoleShellScript}
1823
2133
  var list = tasks.length
1824
2134
  ? '<div class="le-table-wrap"><table><thead><tr><th>Task</th><th>When</th><th>Status</th><th>Last run</th><th></th></tr></thead><tbody>' + tasks.map(renderScheduleRow).join('') + '</tbody></table></div>'
1825
2135
  : '<p class="hint">Nothing scheduled yet.</p>';
1826
- return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the Messaging tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
2136
+ return '<p class="muted" style="margin:0 0 20px">Workflows and messages this agent runs on a timer, like a cron job. A workflow schedule starts with the workflow&#39;s start message as it is at each run. Each run works like an app calling it over HTTP: an approval goes wherever the Messaging tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
1827
2137
  '<section><h3>Scheduled (' + active.length + ' active)</h3>' + list + '</section>' +
1828
2138
  '<section><h3>New schedule</h3>' +
1829
2139
  '<form class="add-source" id="scheduleForm" style="max-width:560px">' +
1830
- '<label>Message <span class="hint">(what the agent receives each time, as if someone typed it)</span><textarea name="message" required placeholder="Summarize yesterday&#39;s support tickets and post the summary."></textarea></label>' +
2140
+ (schedulesWorkflows.length
2141
+ ? '<div class="body-field">Runs' +
2142
+ '<div class="schedule-when"><input type="radio" name="runs" value="workflow" id="runsWorkflow" checked><label for="runsWorkflow">A workflow</label>' +
2143
+ '<select name="workflow_id" aria-label="Workflow">' + schedulesWorkflows.map(function (w) {
2144
+ return '<option value="' + escapeHtml(w.id) + '">' + escapeHtml(w.title) + '</option>';
2145
+ }).join('') + '</select></div>' +
2146
+ '<div class="workflow-params" id="scheduleParams" style="margin:0 0 4px 22px"></div>' +
2147
+ '<p class="hint" style="margin:0 0 4px 22px">Each run starts with the workflow&#39;s start message as it is then, so changing the workflow changes what runs.</p>' +
2148
+ '<div class="schedule-when"><input type="radio" name="runs" value="message" id="runsMessage"><label for="runsMessage">A message</label></div>' +
2149
+ '</div>'
2150
+ : '') +
2151
+ '<label id="scheduleMessageField"' + (schedulesWorkflows.length ? ' hidden' : '') + '>Message <span class="hint">(what the agent receives each time, as if someone typed it)</span><textarea name="message" placeholder="Summarize yesterday&#39;s support tickets and post the summary."></textarea></label>' +
1831
2152
  '<div class="body-field">When' +
1832
2153
  '<div class="schedule-when"><input type="radio" name="when" value="every" id="whenEvery" checked><label for="whenEvery">Every</label>' +
1833
2154
  '<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
@@ -1845,6 +2166,15 @@ ${consoleShellScript}
1845
2166
  '</form></section>';
1846
2167
  }
1847
2168
 
2169
+ // The New schedule form's parameter fields ("param:<name>"), filled ones only.
2170
+ function scheduleParams(data) {
2171
+ var params = {};
2172
+ data.forEach(function (value, key) {
2173
+ if (key.indexOf('param:') === 0 && String(value).trim()) params[key.slice(6)] = String(value).trim();
2174
+ });
2175
+ return params;
2176
+ }
2177
+
1848
2178
  // A date field's value ("2026-11-02T09:00") goes as it is, for the
1849
2179
  // server to read in the workspace's time zone; with no workspace time
1850
2180
  // zone known, it's the browser's.
@@ -1872,15 +2202,53 @@ ${consoleShellScript}
1872
2202
  request.then(function () { setTimeout(function () { loadSchedulesTab(name); }, action === 'run' ? 800 : 0); });
1873
2203
  });
1874
2204
  }
2205
+ var notifyToggles = content.querySelectorAll('.schedule-notify-toggle');
2206
+ for (var n = 0; n < notifyToggles.length; n++) {
2207
+ notifyToggles[n].addEventListener('change', function (ev) {
2208
+ var box = ev.currentTarget;
2209
+ box.disabled = true;
2210
+ fetchJsonRetryOnce('/schedules/' + encodeURIComponent(box.dataset.id) + '/notify', {
2211
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ notify: box.checked }),
2212
+ }).then(function (r) {
2213
+ box.disabled = false;
2214
+ if (!r.ok) { box.checked = !box.checked; alert('Could not change it: ' + (r.body.error || 'unknown error')); }
2215
+ });
2216
+ });
2217
+ }
1875
2218
  var toApprovals = content.querySelectorAll('.schedule-to-approvals');
1876
2219
  for (var j = 0; j < toApprovals.length; j++) {
1877
2220
  toApprovals[j].addEventListener('click', function (ev) { ev.preventDefault(); switchTab('messaging'); });
1878
2221
  }
1879
2222
  var form = content.querySelector('#scheduleForm');
1880
2223
  var result = content.querySelector('#scheduleResult');
2224
+ var messageField = content.querySelector('#scheduleMessageField');
2225
+ var runsRadios = content.querySelectorAll('input[name="runs"]');
2226
+ var paramsBox = content.querySelector('#scheduleParams');
2227
+ var workflowSelect = form.querySelector('select[name="workflow_id"]');
2228
+ // One field per parameter of the chosen workflow.
2229
+ function renderScheduleParams() {
2230
+ if (!paramsBox || !workflowSelect) return;
2231
+ var w = schedulesWorkflows.filter(function (x) { return x.id === workflowSelect.value; })[0];
2232
+ var parameters = (w && w.parameters) || {};
2233
+ paramsBox.innerHTML = Object.keys(parameters).map(function (p) {
2234
+ return '<label class="workflow-param">' + escapeHtml(p) + (parameters[p].required ? '' : ' <span class="hint">(optional)</span>') +
2235
+ '<input name="param:' + escapeHtml(p) + '" placeholder="' + escapeHtml(parameters[p].description || '') + '"></label>';
2236
+ }).join('');
2237
+ }
2238
+ renderScheduleParams();
2239
+ if (workflowSelect) workflowSelect.addEventListener('change', renderScheduleParams);
2240
+ for (var k = 0; k < runsRadios.length; k++) {
2241
+ runsRadios[k].addEventListener('change', function () {
2242
+ var byMessage = form.querySelector('input[name="runs"]:checked').value === 'message';
2243
+ messageField.hidden = !byMessage;
2244
+ if (paramsBox) paramsBox.hidden = byMessage;
2245
+ });
2246
+ }
1881
2247
  form.addEventListener('submit', function (ev) {
1882
2248
  ev.preventDefault();
1883
2249
  var data = new FormData(form);
2250
+ var runsWorkflow = data.get('runs') === 'workflow';
2251
+ if (!runsWorkflow && !String(data.get('message') || '').trim()) { result.textContent = 'Write the message the agent receives.'; return; }
1884
2252
  var schedule;
1885
2253
  if (data.get('when') === 'once') {
1886
2254
  if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
@@ -1895,7 +2263,10 @@ ${consoleShellScript}
1895
2263
  result.textContent = 'Scheduling' + String.fromCharCode(8230);
1896
2264
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
1897
2265
  method: 'POST', headers: { 'content-type': 'application/json' },
1898
- body: JSON.stringify({ message: data.get('message'), schedule: schedule, task_name: data.get('task_name'), notify: data.get('notify') === 'on' }),
2266
+ body: JSON.stringify(Object.assign(
2267
+ runsWorkflow ? { workflow_id: data.get('workflow_id'), params: scheduleParams(data) } : { message: data.get('message') },
2268
+ { schedule: schedule, task_name: data.get('task_name'), notify: data.get('notify') === 'on' }
2269
+ )),
1899
2270
  }).then(function (r) {
1900
2271
  if (!r.ok) { result.textContent = r.body.error || 'Could not schedule'; return; }
1901
2272
  loadSchedulesTab(name);
@@ -1912,6 +2283,7 @@ ${consoleShellScript}
1912
2283
  if (!r.ok) throw new Error(r.body.error || 'request failed');
1913
2284
  var tasks = r.body.tasks || [];
1914
2285
  schedulesTimeZone = r.body.time_zone || null;
2286
+ schedulesWorkflows = r.body.workflows || [];
1915
2287
  content.innerHTML = renderSchedulesHtml(tasks);
1916
2288
  wireSchedulesHandlers(name);
1917
2289
  leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
@@ -8,14 +8,10 @@ export interface SkillContent {
8
8
  body: string;
9
9
  }
10
10
  export declare function readSkill(agentName: string, skillId: string): SkillContent;
11
- /** Creates the skill if `skillId` doesn't exist yet, or overwrites it in
12
- * place if it does — same "this is the new content, not a diff to
13
- * merge" semantics as a PUT. Frontmatter is regenerated from `skillId`
14
- * (as `name`) and `description`; any *other* frontmatter keys a skill
15
- * might have (e.g. `paths` — see SkillFrontmatter's own doc comment) are
16
- * intentionally not preserved here, since this admin UI has no field for
17
- * them. A skill that needs those is still editable by hand — this just
18
- * isn't the tool for it. */
11
+ /** Creates the skill if `skillId` doesn't exist yet, or replaces its
12
+ * description and body if it does. Its other front matter is kept: a
13
+ * workflow's start message, trigger and parameters (core/workflows.ts),
14
+ * `paths` and anything hand-written, none of which the Skills tab edits. */
19
15
  export declare function writeSkill(agentName: string, skillId: string, content: {
20
16
  description: string;
21
17
  body: string;
@@ -9,10 +9,11 @@
9
9
  // this repo's own example agents actually uses. Read-only display of a
10
10
  // nested skill (via describeAgent's own skills list) still works fine —
11
11
  // only adding/editing/removing one through this UI is out of scope.
12
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { parseSkillFile } from '../core/skillgarden/index.js';
15
15
  import { agentDir } from '../core/gateway-tools.js';
16
+ import { writeSkillFile } from '../core/workflows.js';
16
17
  export class SkillInvalidIdError extends Error {
17
18
  }
18
19
  export class SkillNotFoundError extends Error {
@@ -43,20 +44,15 @@ export function readSkill(agentName, skillId) {
43
44
  const { frontmatter, body } = parseSkillFile(readFileSync(path, 'utf8'), skillId);
44
45
  return { id: skillId, description: frontmatter.description ?? '', body };
45
46
  }
46
- /** Creates the skill if `skillId` doesn't exist yet, or overwrites it in
47
- * place if it does — same "this is the new content, not a diff to
48
- * merge" semantics as a PUT. Frontmatter is regenerated from `skillId`
49
- * (as `name`) and `description`; any *other* frontmatter keys a skill
50
- * might have (e.g. `paths` — see SkillFrontmatter's own doc comment) are
51
- * intentionally not preserved here, since this admin UI has no field for
52
- * them. A skill that needs those is still editable by hand — this just
53
- * isn't the tool for it. */
47
+ /** Creates the skill if `skillId` doesn't exist yet, or replaces its
48
+ * description and body if it does. Its other front matter is kept: a
49
+ * workflow's start message, trigger and parameters (core/workflows.ts),
50
+ * `paths` and anything hand-written, none of which the Skills tab edits. */
54
51
  export function writeSkill(agentName, skillId, content) {
55
52
  const path = skillFilePath(agentName, skillId);
56
53
  const description = content.description.replace(/\n/g, ' ').trim();
57
- const frontmatter = `---\nname: ${skillId}\ndescription: ${JSON.stringify(description)}\n---\n\n`;
58
- mkdirSync(dirname(path), { recursive: true });
59
- writeFileSync(path, frontmatter + content.body.trim() + '\n');
54
+ const existing = existsSync(path) ? parseSkillFile(readFileSync(path, 'utf8'), skillId).frontmatter : {};
55
+ writeSkillFile(path, { ...existing, name: skillId, description }, content.body);
60
56
  }
61
57
  export function deleteSkill(agentName, skillId) {
62
58
  const path = skillFilePath(agentName, skillId);