loopengine 0.3.8 → 0.3.10

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.
@@ -438,10 +438,6 @@ ${consoleShellScript}
438
438
  return attempt().catch(function () { return attempt(); });
439
439
  }
440
440
 
441
- function badge(label) {
442
- return '<span class="badge ' + (label === 'custom' ? 'badge-custom' : 'badge-default') + '">' + escapeHtml(label) + '</span>';
443
- }
444
-
445
441
  // A small, deliberately non-CommonMark markdown-to-HTML renderer for
446
442
  // the skill Body field's Preview toggle (see wireSkillsHandlers) — this
447
443
  // page stays a self-contained string, no build step, no CDN (same
@@ -732,7 +728,7 @@ ${consoleShellScript}
732
728
  '<span><a href="?agent=' + encodeURIComponent(agentName) + '&amp;tab=schedules#schedule-' + escapeHtml(x.taskId) + '" class="workflow-schedule-link" data-task="' + escapeHtml(x.taskId) + '" title="See it in the Schedules tab">' +
733
729
  escapeHtml(x.describe.charAt(0).toUpperCase() + x.describe.slice(1)) + '</a></span>' + state +
734
730
  (values ? '<span class="hint">' + values + '</span>' : '') +
735
- (x.notify ? '<span class="hint">results go to your messaging app</span>' : '') +
731
+ (x.notify ? '<span class="hint">messages you the result when it finishes</span>' : '') +
736
732
  '<span style="display:flex;gap:6px;margin-left:auto">' +
737
733
  (x.status === 'paused' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="resume">Resume</button>'
738
734
  : x.status === 'scheduled' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="pause">Pause</button>' : '') +
@@ -899,6 +895,7 @@ ${consoleShellScript}
899
895
  if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
900
896
  var workflows = r.body.workflows || [];
901
897
  fillWorkflowsCard(workflows);
898
+ leShell.setCount('workflows', workflows.length);
902
899
  workflowsById = {};
903
900
  workflows.forEach(function (w) { workflowsById[w.id] = w; });
904
901
  el.innerHTML = renderWorkflowsHtml(name, workflows);
@@ -1026,7 +1023,7 @@ ${consoleShellScript}
1026
1023
  if (t.filter) rules.push('only when ' + escapeHtml(t.filter.path) + ' is ' + t.filter.values.map(escapeHtml).join(' or '));
1027
1024
  if (t.mapping) rules.push(Object.keys(t.mapping).map(function (p) { return escapeHtml(p) + ' ' + String.fromCharCode(8592) + ' ' + escapeHtml(t.mapping[p]); }).join(', '));
1028
1025
  if (t.payload_param) rules.push('the whole payload goes to ' + escapeHtml(t.payload_param));
1029
- if (t.notify) rules.push('sends each result to you');
1026
+ if (t.notify) rules.push('messages you the result when it finishes');
1030
1027
  if (t.callback_url) rules.push('reports to ' + escapeHtml(t.callback_url));
1031
1028
  // Made by the workflow planner without its secret: it turns on once
1032
1029
  // the sender's signing secret is added.
@@ -1072,7 +1069,7 @@ ${consoleShellScript}
1072
1069
  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>' +
1073
1070
  '<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>'
1074
1071
  : '<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>') +
1075
- '<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>' +
1072
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
1076
1073
  '<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>' +
1077
1074
  '<div style="display:flex;gap:8px"><button type="submit" class="primary">Make the URL</button></div>' +
1078
1075
  '<div class="hint hook-form-result"></div></form>';
@@ -1224,7 +1221,11 @@ ${consoleShellScript}
1224
1221
  var action = btn.dataset.action;
1225
1222
  if (action === 'cancel' && btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
1226
1223
  btn.disabled = true;
1227
- fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () { loadWorkflowsSection(name); });
1224
+ fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () {
1225
+ loadWorkflowsSection(name);
1226
+ // The Schedules tab's count, and the Overview's tile.
1227
+ fillOverviewCards(name);
1228
+ });
1228
1229
  });
1229
1230
  }
1230
1231
  card.querySelector('.workflow-run').addEventListener('click', function (ev) {
@@ -1277,6 +1278,7 @@ ${consoleShellScript}
1277
1278
  var waiting = active.filter(function (t) { return t.status === 'awaiting_approval'; }).length;
1278
1279
  var next = active.map(function (t) { return t.next_run_at; }).filter(Boolean).sort()[0];
1279
1280
  set('ovSchedulesValue', String(active.length));
1281
+ leShell.setCount('schedules', active.length);
1280
1282
  var schedulesDetail = [];
1281
1283
  if (waiting) schedulesDetail.push(waiting + ' waiting for approval');
1282
1284
  if (next) schedulesDetail.push('Next run ' + escapeHtml(new Date(next).toLocaleString()));
@@ -1310,7 +1312,10 @@ ${consoleShellScript}
1310
1312
  '<dt>Provider</dt><dd>' + escapeHtml(cfg.model.provider) + '</dd>' +
1311
1313
  '<dt>Model</dt><dd>' + escapeHtml(cfg.model.model || '(provider default)') + '</dd>' +
1312
1314
  '<dt>Max tokens</dt><dd>' + escapeHtml(cfg.model.maxTokens != null ? cfg.model.maxTokens : '(default)') + '</dd>' +
1313
- '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort || (REASONING_PROVIDERS.indexOf(cfg.model.provider) !== -1 ? '(model default)' : 'n/a')) + '</dd>' +
1315
+ // Unset is low for OpenAI's reasoning models (core/model-calls/openai-model-call.ts).
1316
+ '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort ||
1317
+ (cfg.model.provider === 'openai' && /^(gpt-[5-9]|o[0-9])/i.test(cfg.model.model || '') ? 'low (default)'
1318
+ : REASONING_PROVIDERS.indexOf(cfg.model.provider) !== -1 ? '(model default)' : 'n/a')) + '</dd>' +
1314
1319
  '</dl>' +
1315
1320
  '<form class="add-source" id="modelForm" style="display:none">' +
1316
1321
  '<label>Provider' +
@@ -1326,7 +1331,7 @@ ${consoleShellScript}
1326
1331
  '<label>Max tokens <span class="hint">(blank for provider default)</span>' +
1327
1332
  '<input type="number" name="maxTokens" min="1" step="1" value="' + (cfg.model.maxTokens != null ? escapeHtml(cfg.model.maxTokens) : '') + '">' +
1328
1333
  '</label>' +
1329
- '<label id="reasoningEffortField">Reasoning effort <span class="hint">(how much the model thinks before answering: none turns it off, unset uses the model\\\'s default)</span>' +
1334
+ '<label id="reasoningEffortField">Reasoning effort <span class="hint">(how much the model thinks before answering: none turns it off, unset uses the model\\\'s default, and low for OpenAI\\\'s reasoning models)</span>' +
1330
1335
  '<select name="reasoningEffort">' +
1331
1336
  ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].map(function (v) {
1332
1337
  return '<option value="' + v + '"' + (v === (cfg.model.reasoningEffort || '') ? ' selected' : '') + '>' + (v || '(unset)') + '</option>';
@@ -1342,11 +1347,6 @@ ${consoleShellScript}
1342
1347
  return renderOverviewCards(cfg) +
1343
1348
  '<section>' + systemPromptSection + '</section>' +
1344
1349
  '<section>' + modelSection + '</section>' +
1345
- '<section><h3>Hooks</h3><dl class="kv">' +
1346
- '<dt>sessionIdFor</dt><dd>' + badge(cfg.sessionIdFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.sessionIdFor) + '</span></dd>' +
1347
- '<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
1348
- '<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
1349
- '</dl></section>' +
1350
1350
  '<section><h3>Limits &amp; budgets <button type="button" class="edit-btn" id="editLimitsBtn">Edit</button></h3>' +
1351
1351
  '<dl class="kv" id="limitsDisplay">' +
1352
1352
  '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
@@ -1367,7 +1367,6 @@ ${consoleShellScript}
1367
1367
  '<button type="button" id="cancelLimitsBtn">Cancel</button>' +
1368
1368
  '<div class="error" id="limitsError"></div>' +
1369
1369
  '</form>' +
1370
- '<dl class="kv"><dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd></dl>' +
1371
1370
  '</section>';
1372
1371
  }
1373
1372
 
@@ -1956,7 +1955,10 @@ ${consoleShellScript}
1956
1955
  (saved ? '<button type="button" class="delete-btn notifier-remove"' + (locked ? ' disabled' : '') + '>Remove</button>' : '') + '</div>' +
1957
1956
  (isWebhook ? '<p class="hint" style="margin:0">A webhook always sends once saved, alongside the primary messaging app.</p>' : '') +
1958
1957
  '<div class="hint notifier-result"></div>' +
1959
- '</form></details>';
1958
+ '</form>' +
1959
+ // Who can decide and give jobs here: part of this app's settings.
1960
+ (channel === 'slack' || channel === 'telegram' ? renderApproversHtml(n, channel) : '') +
1961
+ '</details>';
1960
1962
  }
1961
1963
 
1962
1964
  function renderConnectionsHtml(n) {
@@ -2011,7 +2013,7 @@ ${consoleShellScript}
2011
2013
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
2012
2014
  }).join('');
2013
2015
  var where = app === 'telegram' ? 'by replying to it' : 'by replying in its thread';
2014
- return '<section class="approvers-section" data-app="' + app + '"><h3>' + title + ' approvers</h3>' +
2016
+ return '<section class="approvers-section" data-app="' + app + '" style="max-width:560px;margin-top:18px;padding-top:14px;border-top:1px solid var(--le-line, #e5e7eb)"><h4 style="margin:0 0 6px">Approvers</h4>' +
2015
2017
  (n.primary !== app ? '<p class="hint" style="margin:0 0 8px">' + title + ' isn&#39;t primary, so requests aren&#39;t sent there until it is. Jobs started there still use this list.</p>' : '') +
2016
2018
  '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request ' + where + ', like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A plain &ldquo;yes&rdquo; runs it; a reply that&#39;s less clear (&ldquo;sure, send it&rdquo;, &ldquo;yes but check the amount&rdquo;) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count, and only approvers can give the agent jobs. Anyone can still ask about a request.</p>' +
2017
2019
  (approvers.length
@@ -2101,8 +2103,7 @@ ${consoleShellScript}
2101
2103
  '<p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
2102
2104
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
2103
2105
  (n.source === 'code' ? '' : renderConnectionsHtml(n)) + '</section>' +
2104
- renderApproversHtml(n, 'slack') +
2105
- renderApproversHtml(n, 'telegram') +
2106
+
2106
2107
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
2107
2108
  }
2108
2109
 
@@ -2251,8 +2252,16 @@ ${consoleShellScript}
2251
2252
  fetchJsonRetryOnce(url).then(function (r) {
2252
2253
  if (!r.ok) { out.textContent = r.body.error || 'Could not check'; return; }
2253
2254
  var b = r.body;
2255
+ // Telegram keeps its last error until a newer one, so it says when.
2256
+ var when = '';
2257
+ if (b.lastErrorAt) {
2258
+ var mins = Math.max(0, Math.round((Date.now() - Date.parse(b.lastErrorAt)) / 60000));
2259
+ when = mins < 2 ? 'just now' : mins < 120 ? mins + ' minutes ago' : mins < 2880 ? Math.round(mins / 60) + ' hours ago' : Math.round(mins / 1440) + ' days ago';
2260
+ when = ' (' + when + ', ' + new Date(b.lastErrorAt).toLocaleString() + ')';
2261
+ }
2254
2262
  out.textContent = (b.pointsHere ? 'Connected' + (b.botUsername ? ' (@' + b.botUsername + ')' : '') + '.' : b.url ? 'The bot sends its updates somewhere else: ' + b.url + '. Connect again to point it here.' : 'The bot isn' + String.fromCharCode(8217) + 't sending its updates anywhere. Connect again.') +
2255
- (b.lastError ? ' Telegram' + String.fromCharCode(8217) + 's last delivery error: ' + b.lastError : '');
2263
+ (b.pending ? ' ' + b.pending + ' update' + (b.pending === 1 ? '' : 's') + ' waiting to be delivered.' : '') +
2264
+ (b.lastError ? ' Telegram' + String.fromCharCode(8217) + 's last delivery error' + when + ': ' + b.lastError + (b.pointsHere && !b.pending ? ' Nothing is waiting, so it' + String.fromCharCode(8217) + 's likely from before and since fixed.' : '') : '');
2256
2265
  });
2257
2266
  });
2258
2267
  }
@@ -2422,8 +2431,8 @@ ${consoleShellScript}
2422
2431
  ? '<div><span class="le-pill bad">Workflow removed</span> <span class="hint">' + escapeHtml(t.workflow_id) + '</span></div>'
2423
2432
  : '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div>') +
2424
2433
  (t.status !== 'cancelled' && t.status !== 'done'
2425
- ? '<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>'
2426
- : (t.notify ? '<div class="hint">Sent each result to you</div>' : '')) + '</td>' +
2434
+ ? '<label class="schedule-notify hint" title="Sent through its Messaging tab: Slack, email, Lark or a webhook"><input type="checkbox" class="schedule-notify-toggle" data-id="' + escapeHtml(t.task_id) + '"' + (t.notify ? ' checked' : '') + '> Message me the result when it finishes</label>'
2435
+ : (t.notify ? '<div class="hint">Messaged you the result when it finished</div>' : '')) + '</td>' +
2427
2436
  '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
2428
2437
  '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
2429
2438
  (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
@@ -2465,7 +2474,7 @@ ${consoleShellScript}
2465
2474
  '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
2466
2475
  '</div>' +
2467
2476
  '<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
2468
- '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent&#39;s messaging connection (set in the Messaging tab)</label>' +
2477
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
2469
2478
  '<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
2470
2479
  '<div id="scheduleResult" class="hint"></div>' +
2471
2480
  '</form></section>';
@@ -3031,7 +3040,7 @@ ${consoleShellScript}
3031
3040
  }
3032
3041
 
3033
3042
  // ---- Apps tab: what's already installed for this agent, a
3034
- // search box over public (npm, tagged loopengine-ability) packages,
3043
+ // search box over official (@loopengine-co on npm) abilities,
3035
3044
  // and a plain spec field for a private ability (a git URL, a local
3036
3045
  // file:../path, or a private registry name) — both installs go
3037
3046
  // through the same POST .../abilities, since installAbility itself
@@ -3076,7 +3085,7 @@ ${consoleShellScript}
3076
3085
  '</tr>';
3077
3086
  }
3078
3087
 
3079
- // A public (npm) or private ability not installed here yet: its
3088
+ // An official or private ability not installed here yet: its
3080
3089
  // details show the permissions it ships with.
3081
3090
  function renderAbilitySearchResultRow(pkg, installedNames, spec) {
3082
3091
  var installSpec = spec || pkg.name;
@@ -3099,15 +3108,15 @@ ${consoleShellScript}
3099
3108
  : '<p class="hint">Nothing added to this agent yet.</p>';
3100
3109
 
3101
3110
  // What this agent has first; then the workspace's connected apps (each
3102
- // with the tools this agent gets), then public and private abilities.
3111
+ // with the tools this agent gets), then official and private abilities.
3103
3112
  return '<h3 style="justify-content:flex-start;align-items:baseline;gap:8px">Installed <span class="hint" style="font-weight:400">the apps and abilities this agent has</span></h3>' + installedHtml +
3104
3113
  // Installing by name or spec is on the console's Abilities page.
3105
3114
  '<p id="abilityInstallStatus" class="hint"></p>' +
3106
3115
  '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Apps <span class="hint" style="font-weight:400">connected in this workspace; choose which of their tools this agent gets, and each one' + String.fromCharCode(8217) + 's permission</span></h3>' +
3107
3116
  '<div id="agentApps" style="margin-bottom:32px"><p class="hint">Loading&hellip;</p></div>' +
3108
- '<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>' +
3117
+ '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Official abilities <span class="hint" style="font-weight:400">published by LoopEngine</span></h3>' +
3109
3118
  '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
3110
- '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
3119
+ '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search official abilities" style="flex:1">' +
3111
3120
  '<button type="submit">Search</button>' +
3112
3121
  '</form>' +
3113
3122
  '<div id="abilitySearchResults" style="margin-bottom:32px"></div>' +
@@ -3325,7 +3334,7 @@ ${consoleShellScript}
3325
3334
  var results = result.body.results || [];
3326
3335
  resultsEl.innerHTML = results.length
3327
3336
  ? '<table><thead><tr><th>Package</th><th>Description</th><th></th></tr></thead><tbody>' + results.map(function (pkg) { return renderAbilitySearchResultRow(pkg, installedNames); }).join('') + '</tbody></table>'
3328
- : '<p class="hint">No public abilities found.</p>';
3337
+ : '<p class="hint">No official abilities found.</p>';
3329
3338
  var installBtns = resultsEl.querySelectorAll('.ability-install-btn');
3330
3339
  for (var i = 0; i < installBtns.length; i++) {
3331
3340
  installBtns[i].addEventListener('click', function (ev2) {
@@ -3610,11 +3619,7 @@ ${consoleShellScript}
3610
3619
 
3611
3620
  function renderSkillsTabHtml(cfg) {
3612
3621
  var listHtml = cfg.skills.length ? cfg.skills.map(renderSkillCard).join('') : '<p class="hint">No skills yet.</p>';
3613
- return '<section><h3>Skills dirs</h3><dl class="kv">' +
3614
- '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
3615
- '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
3616
- '</dl></section>' +
3617
- '<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
3622
+ return '<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
3618
3623
  '<section><h3 id="skillFormHeading">Add a skill</h3>' +
3619
3624
  '<form class="add-source" id="skillForm">' +
3620
3625
  '<label>Id <span class="hint">(lowercase, hyphen-separated — becomes the SKILL.md folder name; nested skills aren\\'t editable here)</span>' +
@@ -4501,7 +4506,7 @@ ${consoleShellScript}
4501
4506
  prompt: cfg.systemPrompt,
4502
4507
  chips: [modelChip(cfg), { text: 'unmatched calls: ' + cfg.permissions.defaultDecision }],
4503
4508
  tab: currentTab,
4504
- counts: { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, actauth: cfg.permissions.rules.length },
4509
+ counts: { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, workflows: cfg.skills.filter(function (s) { return s.workflow; }).length },
4505
4510
  }) +
4506
4511
  '<div class="le-scroll"><div class="le-page" style="display:block">' +
4507
4512
  '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
@@ -15,7 +15,7 @@
15
15
  // leShell.wireModelPicker(form) model dropdown for a provider select
16
16
  // leShell.loadProviderKeys() Promise of {provider: key saved?}, or null
17
17
  // leShell.applyProviderKeys(form, keys, keep) only providers with a key
18
- // leShell.abilityOriginBadge(origin) Public/Private badge for an ability
18
+ // leShell.abilityOriginBadge(origin) Official/Public/Private badge for an ability
19
19
  // leShell.toggleAbilityDetails(btn) an ability's tools, skills and settings
20
20
  // Same embedded-string conventions as the pages themselves: plain
21
21
  // string concatenation and no backslashes inside the script, so nothing
@@ -56,8 +56,8 @@ export const consoleShellScript = `<script>
56
56
  // ability on the Apps tab (id 'abilities', so older links still work).
57
57
  // ?tab=tools and ?tab=skills still open Advanced.
58
58
  var TABS = [
59
- ['overview', 'Overview'], ['workflows', 'Workflows'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
60
- ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment'], ['advanced', 'Advanced'], ['chat', 'Chat']
59
+ ['overview', 'Overview'], ['workflows', 'Workflows'], ['abilities', 'Apps'], ['chat', 'Chat'],
60
+ ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['actauth', 'Permissions'], ['env', 'Environment'], ['advanced', 'Advanced']
61
61
  ];
62
62
 
63
63
  function esc(s) {
@@ -360,11 +360,12 @@ export const consoleShellScript = `<script>
360
360
  return decision ? '<span class="le-pill ' + decision + '">' + DECISION_LABELS[decision] + '</span>' : '<span class="hint">No rule</span>';
361
361
  }
362
362
 
363
- // Public (npm) or Private, with where it came from on hover.
363
+ // Official (@loopengine-co), Public (other npm) or Private, with where
364
+ // it came from on hover.
364
365
  function abilityOriginBadge(origin) {
365
366
  if (!origin) return '';
366
367
  return '<span class="le-pill ' + (origin.kind === 'private' ? 'accent' : '') + '" title="' + esc(origin.label) + '">' +
367
- (origin.kind === 'private' ? 'Private' : 'Public') + '</span> <span class="hint">' + esc(origin.label) + '</span>';
368
+ (origin.kind === 'private' ? 'Private' : origin.kind === 'official' ? 'Official' : 'Public') + '</span> <span class="hint">' + esc(origin.label) + '</span>';
368
369
  }
369
370
 
370
371
 
@@ -251,7 +251,7 @@ ${consoleShellScript}
251
251
  }
252
252
 
253
253
  // ---- Abilities section ----
254
- // How agents reach outside apps. Public abilities (npm) are browsed
254
+ // How agents reach outside apps. Official abilities (npm) are browsed
255
255
  // here and installed from an agent's own Apps tab. A private one
256
256
  // (from the sources in Settings) is installed here into the workspace
257
257
  // (core/workspace-abilities.ts), then added to any agent from its tab.
@@ -271,9 +271,9 @@ ${consoleShellScript}
271
271
  function renderAbilities() {
272
272
  var content = document.getElementById('abilitiesContent');
273
273
  content.innerHTML = '<section>' +
274
- '<h4>Public <span class="hint">npm packages tagged loopengine-ability; add one to an agent from its Apps tab</span></h4>' +
274
+ '<h4>Official <span class="hint">published by LoopEngine; add one to an agent from its Apps tab</span></h4>' +
275
275
  '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
276
- '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
276
+ '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search official abilities" style="flex:1">' +
277
277
  '<button type="submit">Search</button>' +
278
278
  '</form>' +
279
279
  '<div id="abilitySearchResults"><p class="hint">Loading&hellip;</p></div>' +
@@ -443,7 +443,7 @@ ${consoleShellScript}
443
443
  }
444
444
 
445
445
  // Linked from an agent's Apps tab (/config?section=abilities#private-abilities):
446
- // once every list has loaded, so the public results don't push it down.
446
+ // once every list has loaded, so the official results don't push it down.
447
447
  var scrolledToPrivate = false;
448
448
  function scrollToPrivateIfLinked() {
449
449
  if (scrolledToPrivate || location.hash !== '#private-abilities' || !lastPrivate || !workspaceAbilities || lastSearch.q === null) return;
@@ -547,6 +547,8 @@ ${consoleShellScript}
547
547
  // Open tool panels, by ability: { tools, filter, error, busy }.
548
548
  var openTools = {};
549
549
  var appSearch = { q: '', servers: null, error: '' };
550
+ // The "Add your own server" form: open, and what's typed in it.
551
+ var ownForm = { open: false };
550
552
 
551
553
  function appLetter(title) {
552
554
  return '<span class="app-letter" aria-hidden="true">' + escapeHtml((title || '?').charAt(0).toUpperCase()) + '</span>';
@@ -572,6 +574,37 @@ ${consoleShellScript}
572
574
  '<p class="hint app-msg" style="margin:0"></p></form>';
573
575
  }
574
576
 
577
+ // Your company's own servers (mcp-servers.yml): each connects like any
578
+ // app; a saved one not connected can be connected again or removed.
579
+ function renderOwnServers() {
580
+ var saved = apps.saved || [];
581
+ var rows = saved.map(function (s) {
582
+ var connected = apps.connected.some(function (c) { return c.url === s.url; });
583
+ return '<div class="app-row" data-app="' + escapeHtml(s.id) + '"><div class="app-head">' + appLetter(s.title) + '</div>' +
584
+ '<div class="app-row-main"><div><strong>' + escapeHtml(s.title) + '</strong> <span class="badge badge-default">Private</span></div>' +
585
+ '<div class="hint">' + escapeHtml(s.url) + (s.description ? ' &middot; ' + escapeHtml(s.description) : '') + (s.header ? ' &middot; key sent in ' + escapeHtml(s.header) : '') + '</div></div>' +
586
+ '<div class="app-slot">' + (connected
587
+ ? '<span class="badge badge-custom">Connected</span>'
588
+ : '<button type="button" class="app-connect" data-id="' + escapeHtml(s.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Connect</button> <button type="button" class="delete-btn app-forget" data-id="' + escapeHtml(s.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Remove</button>') + '</div></div>';
589
+ }).join('');
590
+ var form = ownForm.open
591
+ ? '<form id="ownServerForm" class="app-form" style="max-width:560px">' +
592
+ '<label>Name<input name="title" required maxlength="60" placeholder="Acme CRM"></label>' +
593
+ '<label>Address<input name="url" type="url" required placeholder="https://mcp.yourcompany.com/mcp"></label>' +
594
+ '<label>What it does <span class="hint">(optional)</span><input name="description" maxlength="200" placeholder="Our customers, orders and support tickets"></label>' +
595
+ '<label>API key <span class="hint">(if it needs one; saved in this workspace' + String.fromCharCode(8217) + 's settings)</span><input name="key" type="password" autocomplete="off"></label>' +
596
+ '<label>Header for the key <span class="hint">(optional; empty sends it as Authorization: Bearer ' + String.fromCharCode(8230) + ')</span><input name="header" placeholder="X-Api-Key" pattern="[A-Za-z0-9-]+"></label>' +
597
+ '<div class="app-foot"><button type="submit">Connect</button><button type="button" class="own-cancel">Cancel</button></div>' +
598
+ '<p class="hint app-msg" style="margin:0"></p></form>'
599
+ : '<button type="button" class="own-add"' + (apps.canConnect ? '' : ' disabled') + '>Add your own server</button>';
600
+ return '<section id="own-servers"><h3>Your own servers</h3>' +
601
+ '<p class="hint">Your company' + String.fromCharCode(8217) + 's own MCP server, by its address: it connects like any app, and only this workspace sees it. Servers that only offer a sign-in (OAuth) can' + String.fromCharCode(8217) + 't be connected yet.</p>' +
602
+ (rows ? '<div class="app-list" style="margin-bottom:10px">' + rows + '</div>' : '') + form + '</section>';
603
+ }
604
+
605
+ // By name: form.title is the form's own title attribute.
606
+ function ownField(form, name) { return form.querySelector('[name=' + name + ']').value; }
607
+
575
608
  function renderApps() {
576
609
  var el = document.getElementById('appsContent');
577
610
  if (!apps) return;
@@ -601,6 +634,7 @@ ${consoleShellScript}
601
634
  }).join('') + '</div>' +
602
635
  '<p class="hint">Each is the app' + String.fromCharCode(8217) + 's own MCP server. Apps that only offer a sign-in (OAuth) can' + String.fromCharCode(8217) + 't be connected yet; their Connect says so.</p>' +
603
636
  '</section>' +
637
+ renderOwnServers() +
604
638
  '<section><h3>More apps</h3>' +
605
639
  '<form id="appSearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px"><input type="search" name="q" placeholder="Search the MCP Registry, e.g. calendly" aria-label="Search apps" style="flex:1" value="' + escapeHtml(appSearch.q) + '"><button type="submit">Search</button></form>' +
606
640
  '<div id="appSearchResults">' + renderAppSearch() + '</div>' +
@@ -716,6 +750,26 @@ ${consoleShellScript}
716
750
  return;
717
751
  }
718
752
  if (t.classList.contains('app-cancel')) { renderApps(); return; }
753
+ if (t.classList.contains('own-add')) {
754
+ ownForm.open = true;
755
+ renderApps();
756
+ document.querySelector('#ownServerForm input[name=title]').focus();
757
+ return;
758
+ }
759
+ if (t.classList.contains('own-cancel')) { ownForm.open = false; renderApps(); return; }
760
+ if (t.classList.contains('app-forget')) {
761
+ if (t.dataset.confirming !== '1') { t.dataset.confirming = '1'; t.textContent = 'Click again to remove'; return; }
762
+ t.disabled = true;
763
+ fetch('/config/apps/servers?id=' + encodeURIComponent(t.dataset.id), { method: 'DELETE' })
764
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
765
+ .then(function (r) {
766
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
767
+ apps = r.body;
768
+ renderApps();
769
+ })
770
+ .catch(function (err) { t.disabled = false; t.textContent = 'Could not remove: ' + err.message; });
771
+ return;
772
+ }
719
773
  if (t.classList.contains('app-disconnect')) {
720
774
  if (t.dataset.confirming !== '1') { t.dataset.confirming = '1'; t.textContent = 'Click again to disconnect'; return; }
721
775
  t.disabled = true;
@@ -760,6 +814,28 @@ ${consoleShellScript}
760
814
  .catch(function (err) { appSearch.error = 'Could not search: ' + err.message; document.getElementById('appSearchResults').innerHTML = renderAppSearch(); });
761
815
  return;
762
816
  }
817
+ if (form.id === 'ownServerForm') {
818
+ var ownBtn = form.querySelector('button[type=submit]');
819
+ var ownMsg = form.querySelector('.app-msg');
820
+ ownBtn.disabled = true;
821
+ ownMsg.className = 'hint app-msg';
822
+ ownMsg.textContent = 'Connecting' + String.fromCharCode(8230) + ' it asks the server for its tools and sets their permissions, which can take a minute.';
823
+ fetch('/config/apps/servers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: ownField(form, 'title'), url: ownField(form, 'url'), description: ownField(form, 'description'), key: ownField(form, 'key'), header: ownField(form, 'header') }) })
824
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
825
+ .then(function (r) {
826
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
827
+ apps = r.body;
828
+ ownForm.open = false;
829
+ if (r.body.connectedAbility) loadTools(r.body.connectedAbility, false);
830
+ else renderApps();
831
+ })
832
+ .catch(function (err) {
833
+ ownBtn.disabled = false;
834
+ ownMsg.className = 'error app-msg';
835
+ ownMsg.textContent = err.message;
836
+ });
837
+ return;
838
+ }
763
839
  if (!form.dataset.connect) return;
764
840
  var btn = form.querySelector('button[type=submit]');
765
841
  var msg = form.querySelector('.app-msg');
@@ -805,12 +881,12 @@ ${consoleShellScript}
805
881
  rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
806
882
  '</tbody></table></section>' +
807
883
  (d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
808
- '<section><h3>Private ability sources</h3><p class="muted" style="margin:0 0 10px">Where your own abilities come from, listed under Private on the Abilities page next to the public ones on npm.</p><div class="source" id="abilitySourcesBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' +
884
+ '<section><h3>Private ability sources</h3><p class="muted" style="margin:0 0 10px">Where your own abilities come from, listed under Private on the Abilities page next to LoopEngine&#39;s official ones.</p><div class="source" id="abilitySourcesBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' +
809
885
  '<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
810
886
  }
811
887
 
812
888
  // ---- Private ability sources (Settings) ----
813
- // Where this project's own abilities come from, next to the public ones
889
+ // Where this project's own abilities come from, next to the official ones
814
890
  // on npm: a GitHub org or user, or a private npm registry
815
891
  // (core/ability-sources.ts). There's no folder kind here: this server
816
892
  // may run in the cloud, so local files are uploaded on the Abilities
@@ -127,6 +127,8 @@ export declare function setupAgentTelegramWebhook(config: AgentConfig, baseUrl:
127
127
  export declare function agentTelegramWebhookInfo(config: AgentConfig): Promise<{
128
128
  url?: string;
129
129
  lastError?: string;
130
+ lastErrorAt?: string;
131
+ pending?: number;
130
132
  pointsHere: boolean;
131
133
  botUsername?: string;
132
134
  }>;
@@ -161,7 +161,7 @@ export function jobAllowed(config, app, message) {
161
161
  const home = app === 'slack' ? file?.channelId : file?.chatId;
162
162
  if (!message.direct && home && home === message.conversation)
163
163
  return { allowed: true };
164
- return { allowed: false, message: `Jobs by direct message need approvers: add yourself in this agent's Messaging tab in LoopEngine (${APP_NAMES[app]} section).` };
164
+ return { allowed: false, message: `Jobs by direct message need approvers: in LoopEngine, open this agent's Messaging tab, then ${APP_NAMES[app]} → Approvers, and add yourself.` };
165
165
  }
166
166
  /** Replaces the approver list on the agent's saved connection for `app`,
167
167
  * primary or not. Throws NotifierFileError when there's no connection
@@ -224,6 +224,8 @@ export async function agentTelegramWebhookInfo(config) {
224
224
  return {
225
225
  ...(info.url ? { url: info.url } : {}),
226
226
  ...(info.lastError ? { lastError: info.lastError } : {}),
227
+ ...(info.lastErrorAt ? { lastErrorAt: info.lastErrorAt } : {}),
228
+ ...(info.pending ? { pending: info.pending } : {}),
227
229
  pointsHere: Boolean(info.url?.endsWith(`/hooks/telegram/${encodeURIComponent(config.name)}`)),
228
230
  ...(botUsername ? { botUsername } : {}),
229
231
  };
@@ -1433,7 +1433,7 @@ ${consoleShellScript}
1433
1433
  var counts = {};
1434
1434
  if (cfg) {
1435
1435
  chips.push({ text: typeof cfg.model === 'string' ? cfg.model : cfg.model.provider + ' \u00b7 ' + (cfg.model.model || 'default model'), mono: true });
1436
- counts = { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, actauth: cfg.permissions.rules.length };
1436
+ counts = { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount };
1437
1437
  }
1438
1438
  head.innerHTML = leShell.renderHead({ name: name, prompt: agent.systemPrompt, chips: chips, tab: 'chat', counts: counts });
1439
1439
  }
@@ -11,10 +11,12 @@
11
11
  import { createScheduledTask, pauseScheduledTask } from '../core/scheduler.js';
12
12
  import { workspaceTimeZone } from '../core/time-zone.js';
13
13
  import { STARTER_TEMPLATES, starterTemplate, templateScheduleStart } from '../core/system-agents/templates.js';
14
- import { ActauthRuleExistsError, addActauthRule, setDefaultDecision } from './actauth-admin.js';
14
+ import { ActauthRuleExistsError, addActauthRule, applyPlannedRules, setDefaultDecision } from './actauth-admin.js';
15
15
  import { setAgentEnvVar } from './env-admin.js';
16
16
  import { freeAgentName } from './interview-admin.js';
17
17
  import { writeSkill } from './skills-admin.js';
18
+ import { agentDir } from '../core/gateway-tools.js';
19
+ import { setWorkflowFields } from '../core/workflows.js';
18
20
  /** What a template's schedule runs (its workflow, when it comes from one,
19
21
  * or its message), and whether it sends each result. */
20
22
  function scheduleRuns(schedule) {
@@ -84,7 +86,7 @@ export function previewTemplate(id, deps) {
84
86
  // A key set for the whole project already counts for a new agent.
85
87
  keys: keyGroups(template).map((names) => ({ names, set: names.some((n) => Boolean(envValue(name, n))) })),
86
88
  settings: setup.env ?? {},
87
- schedule: setup.schedule ? `${describeTemplateSchedule(setup.schedule)} (created paused)` : null,
89
+ schedule: setup.schedule ? `${describeTemplateSchedule(setup.schedule)} (created paused)${setup.schedule.notify ? '; it messages you the result when it finishes, through its Messaging tab: Slack, email, Lark or a webhook' : ''}` : null,
88
90
  needsApprovals: setup.needsApprovals,
89
91
  notes: setup.notes ?? [],
90
92
  };
@@ -104,8 +106,19 @@ export function templateChecklist(template, name, deps, failed = [], appsToConne
104
106
  for (const group of missing.filter((g) => g.length > 1)) {
105
107
  items.push({ label: `Add one of ${group.slice(0, -1).join(', ')} or ${group.at(-1)}`, href: tab('env') });
106
108
  }
107
- if (template.setup.needsApprovals && !deps.messagingConfigured(name)) {
108
- items.push({ label: 'Choose where approval requests go: Slack, email, Lark or a webhook', href: tab('messaging') });
109
+ // A schedule or webhook that sends each result needs the same connection.
110
+ const webhookWorkflows = (template.setup.skills ?? []).filter((s) => s.workflow?.webhook);
111
+ const sendsResults = Boolean(template.setup.schedule?.notify) || webhookWorkflows.length > 0;
112
+ if ((template.setup.needsApprovals || sendsResults) && !deps.messagingConfigured(name)) {
113
+ items.push({
114
+ label: template.setup.needsApprovals
115
+ ? `Choose where approval requests${sendsResults ? ' and results' : ''} go: Slack, email, Lark or a webhook`
116
+ : 'Choose where its results go: Slack, email, Lark or a webhook',
117
+ href: tab('messaging'),
118
+ });
119
+ }
120
+ for (const skill of webhookWorkflows) {
121
+ items.push({ label: `Add a webhook to the "${skill.workflow.title}" workflow with "Message me the result when it finishes" on, then paste its URL into the app that sends the alerts`, href: tab('workflows') });
109
122
  }
110
123
  for (const note of template.setup.notes ?? [])
111
124
  items.push({ label: note });
@@ -126,19 +139,9 @@ export async function createFromTemplate(id, deps) {
126
139
  export async function createFromTemplateObject(template, deps) {
127
140
  const { candidate, setup } = template;
128
141
  const name = freeAgentName(candidate.agent.name, deps.agentExists);
129
- // 1. The agent, and its permissions: everything asks unless a rule says
130
- // otherwise, and the template's rules come first.
142
+ // 1. The agent: everything asks unless a rule says otherwise.
131
143
  await deps.scaffoldAgent(name, candidate.agent.systemPrompt);
132
144
  setDefaultDecision(name, 'ask');
133
- for (const rule of setup.rules) {
134
- try {
135
- addActauthRule(name, { name: rule.name, scope: '*/*', tool: rule.tool, decision: rule.decision });
136
- }
137
- catch (err) {
138
- if (!(err instanceof ActauthRuleExistsError))
139
- throw err;
140
- }
141
- }
142
145
  // 2. Abilities: their tools, skills and rules. One failing doesn't stop
143
146
  // the rest; it heads the checklist.
144
147
  const installed = [];
@@ -185,9 +188,15 @@ export async function createFromTemplateObject(template, deps) {
185
188
  failed.push({ name: app.title, error: installFailureReason(err) });
186
189
  }
187
190
  }
191
+ // The template's rules, now that abilities and apps have added theirs:
192
+ // none twice, a stricter one placed so it counts, none loosened.
193
+ applyPlannedRules(name, setup.rules.map((r) => ({ name: r.name, tool: r.tool, decision: r.decision })));
188
194
  // 3. Its own skills and non-secret settings.
189
- for (const skill of setup.skills ?? [])
195
+ for (const skill of setup.skills ?? []) {
190
196
  writeSkill(name, skill.id, { description: skill.description, body: skill.body });
197
+ if (skill.workflow)
198
+ setWorkflowFields(agentDir(name), skill.id, { title: skill.workflow.title, instructions: skill.body, ...(skill.workflow.parameters ? { parameters: skill.workflow.parameters } : {}) });
199
+ }
191
200
  for (const [variable, value] of Object.entries(setup.env ?? {}))
192
201
  await setAgentEnvVar(name, variable, value);
193
202
  // 4. Load it, now that everything is in place.
@@ -75,7 +75,7 @@ ${consoleShellScript}
75
75
  '<div class="meta">' +
76
76
  '<div><strong>Runs:</strong> ' + esc(t.trigger) + '</div>' +
77
77
  (t.asksFirst.length ? '<div><strong>Asks you first:</strong> ' + esc(t.asksFirst.join(', ')) + '</div>' : '') +
78
- '<div><strong>Installs:</strong> ' + t.abilities.map(function (a) { return '<code>' + esc(a) + '</code>'; }).join(' ') + '</div>' +
78
+ (t.abilities.length || (t.apps || []).length ? '<div><strong>Uses:</strong> ' + t.abilities.map(function (a) { return '<code>' + esc(a) + '</code>'; }).concat((t.apps || []).map(function (a) { return esc(a); })).join(' ') + '</div>' : '') +
79
79
  '</div>' +
80
80
  '<div class="row"><button type="button" class="primary tp-use">Use this template</button></div>' +
81
81
  '<div class="tp-slot"></div></div>';
@@ -108,17 +108,18 @@ ${consoleShellScript}
108
108
  slot.innerHTML = '<div class="tp-preview">' +
109
109
  '<div><strong>Agent:</strong> <code>' + esc(p.name) + '</code></div>' +
110
110
  '<div><strong>Instructions</strong> <span class="hint">(edit them later in its Overview tab)</span><pre>' + esc(p.systemPrompt) + '</pre></div>' +
111
- '<div><strong>Installs</strong> <span class="hint">(abilities: their tools, skills and permission rules)</span>' +
111
+ (p.abilities.length ? '<div><strong>Installs</strong> <span class="hint">(abilities: their tools, skills and permission rules)</span>' +
112
112
  listHtml(p.abilities.map(function (a) { return '<li><code>' + esc(a) + '</code></li>'; })) +
113
- (canInstall ? '' : '<p class="error" style="margin:4px 0 0">Installing abilities needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server. Without it, the agent is created and its abilities head the checklist.</p>') + '</div>' +
113
+ (canInstall ? '' : '<p class="error" style="margin:4px 0 0">Installing abilities needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server. Without it, the agent is created and its abilities head the checklist.</p>') + '</div>' : '') +
114
114
  (p.apps && p.apps.length ? '<div><strong>Apps it uses</strong> <span class="hint">(connected on the <a href="/config?section=apps">Apps</a> page; one that isn' + String.fromCharCode(8217) + 't connected yet heads the checklist)</span>' + listHtml(p.apps.map(function (a) { return '<li>' + esc(a) + '</li>'; })) + '</div>' : '') +
115
115
  '<div><strong>Permissions</strong> <span class="hint">(anything not listed asks you first)</span>' +
116
116
  listHtml(p.rules.map(function (rule) { return '<li><code>' + esc(rule.tool) + '</code>: ' + esc(DECISIONS[rule.decision] || rule.decision) + '</li>'; })) + '</div>' +
117
- '<div><strong>Keys it needs</strong>' +
117
+ (p.skills && p.skills.length ? '<div><strong>Skills it starts with</strong> <span class="hint">(edit them later in Advanced ' + String.fromCharCode(8594) + ' Skills)</span>' + listHtml(p.skills.map(function (sk) { return '<li><code>' + esc(sk) + '</code></li>'; })) + '</div>' : '') +
118
+ (p.keys.length ? '<div><strong>Keys it needs</strong>' +
118
119
  listHtml(p.keys.map(function (k) {
119
120
  var names = k.names.length > 1 ? 'one of ' + k.names.join(', ') : k.names[0];
120
121
  return '<li>' + esc(names) + (k.set ? ' <span class="hint">' + CHECK + ' already set</span>' : '') + '</li>';
121
- })) + '</div>' +
122
+ })) + '</div>' : '') +
122
123
  (settings.length ? '<div><strong>Settings filled in:</strong> ' + settings.map(function (s) { return '<code>' + esc(s) + '=' + esc(p.settings[s]) + '</code>'; }).join(', ') + '</div>' : '') +
123
124
  (p.schedule ? '<div><strong>Schedule:</strong> ' + esc(p.schedule) + '</div>' : '') +
124
125
  (p.needsApprovals ? '<div><strong>Approvals:</strong> requests reach you wherever its Messaging tab sends them (Slack, email, Lark or a webhook).</div>' : '') +