loopengine 0.1.93 → 0.1.95

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.
@@ -533,7 +533,7 @@ ${consoleShellScript}
533
533
  // has no spec to repopulate an edit form from, so it just gets a blank
534
534
  // cell — not a button that would 404 the moment it's clicked.
535
535
  function renderLocalTools(tools) {
536
- if (!tools.length) return '<p class="muted">No tools yet. Add one below, install an ability, or connect an integration.</p>';
536
+ if (!tools.length) return '<p class="muted">No tools yet. Install an ability, or add one below.</p>';
537
537
  var rows = tools.map(function (t) {
538
538
  var actions = t.httpToolEditable
539
539
  ? '<button type="button" class="edit-http-tool-btn" data-name="' + escapeHtml(t.name) + '">Edit</button>'
@@ -1484,10 +1484,22 @@ ${consoleShellScript}
1484
1484
  scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
1485
1485
  };
1486
1486
 
1487
+ // The workspace's time zone, from GET /agents/:name/schedules (an
1488
+ // older project adapter doesn't send it: then the browser's own).
1489
+ var schedulesTimeZone = null;
1490
+
1491
+ function fmtScheduleTime(iso) {
1492
+ var d = new Date(iso);
1493
+ if (schedulesTimeZone) {
1494
+ try { return d.toLocaleString(undefined, { timeZone: schedulesTimeZone, timeZoneName: 'short' }); } catch (e) {}
1495
+ }
1496
+ return d.toLocaleString();
1497
+ }
1498
+
1487
1499
  function scheduleText(s) {
1488
1500
  // A repeating task's anchor is its first run, so it says when the
1489
1501
  // later ones fall (e.g. every 1d from 9:00).
1490
- return s.run_at ? 'Once, ' + new Date(s.run_at).toLocaleString() : 'Every ' + escapeHtml(s.every) + (s.anchor ? '<div class="hint">from ' + escapeHtml(new Date(s.anchor).toLocaleString()) + '</div>' : '');
1502
+ return s.run_at ? 'Once, ' + escapeHtml(fmtScheduleTime(s.run_at)) : 'Every ' + escapeHtml(s.every) + (s.anchor ? '<div class="hint">from ' + escapeHtml(fmtScheduleTime(s.anchor)) + '</div>' : '');
1491
1503
  }
1492
1504
 
1493
1505
  function renderScheduleRow(t) {
@@ -1499,17 +1511,17 @@ ${consoleShellScript}
1499
1511
  if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
1500
1512
  var last = t.last_run_at
1501
1513
  ? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
1502
- '<span class="hint">' + escapeHtml(new Date(t.last_run_at).toLocaleString()) + '</span>' +
1514
+ '<span class="hint">' + escapeHtml(fmtScheduleTime(t.last_run_at)) + '</span>' +
1503
1515
  '<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
1504
1516
  : '<span class="hint">not run yet</span>';
1505
1517
  var history = (t.history || []).slice().reverse().map(function (h) {
1506
- return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(new Date(h.ran_at).toLocaleString()) + '</span>' +
1518
+ return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(fmtScheduleTime(h.ran_at)) + '</span>' +
1507
1519
  '<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
1508
1520
  }).join('');
1509
1521
  return '<tr>' +
1510
1522
  '<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>' : '') +
1511
1523
  '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
1512
- '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(new Date(t.next_run_at).toLocaleString()) + '</div>' : '') + '</td>' +
1524
+ '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
1513
1525
  '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
1514
1526
  (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
1515
1527
  '<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
@@ -1533,7 +1545,8 @@ ${consoleShellScript}
1533
1545
  '<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
1534
1546
  '<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option><option value="w">weeks</option></select>' +
1535
1547
  '<label for="everyStart">starting</label><input type="datetime-local" name="everyStart" id="everyStart" aria-label="First run"></div>' +
1536
- '<p class="hint" style="margin:0 0 4px 22px">Optional. The first run, which sets the time of day (and the weekday, for weeks) every later run keeps, e.g. every 1 day starting tomorrow 09:00. Without it, the first run is one interval from now.</p>' +
1548
+ '<p class="hint" style="margin:0 0 4px 22px">Optional. The first run, which sets the time of day (and the weekday, for weeks) every later run keeps, e.g. every 1 day starting tomorrow 09:00. Without it, the first run is one interval from now.' +
1549
+ (schedulesTimeZone ? ' Times are in ' + escapeHtml(schedulesTimeZone.replace(/_/g, ' ')) + ' (<a href="/config?section=settings">change</a>).' : '') + '</p>' +
1537
1550
  '<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
1538
1551
  '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
1539
1552
  '</div>' +
@@ -1544,6 +1557,13 @@ ${consoleShellScript}
1544
1557
  '</form></section>';
1545
1558
  }
1546
1559
 
1560
+ // A date field's value ("2026-11-02T09:00") goes as it is, for the
1561
+ // server to read in the workspace's time zone; with no workspace time
1562
+ // zone known, it's the browser's.
1563
+ function scheduleFormTime(value) {
1564
+ return schedulesTimeZone ? value : new Date(value).toISOString();
1565
+ }
1566
+
1547
1567
  function wireSchedulesHandlers(name) {
1548
1568
  var content = schedulesContentEl();
1549
1569
  if (!content) return;
@@ -1576,14 +1596,13 @@ ${consoleShellScript}
1576
1596
  var schedule;
1577
1597
  if (data.get('when') === 'once') {
1578
1598
  if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
1579
- schedule = { run_at: new Date(data.get('runAt')).toISOString() };
1599
+ schedule = { run_at: scheduleFormTime(data.get('runAt')) };
1580
1600
  } else {
1581
- // Weeks go to the scheduler as days; the start comes from the
1582
- // browser's own time zone.
1601
+ // Weeks go to the scheduler as days.
1583
1602
  var unit = data.get('everyUnit');
1584
1603
  var n = Number(data.get('everyN')) || 1;
1585
1604
  schedule = { every: unit === 'w' ? String(n * 7) + 'd' : String(n) + unit };
1586
- if (data.get('everyStart')) schedule.start_at = new Date(data.get('everyStart')).toISOString();
1605
+ if (data.get('everyStart')) schedule.start_at = scheduleFormTime(data.get('everyStart'));
1587
1606
  }
1588
1607
  result.textContent = 'Scheduling' + String.fromCharCode(8230);
1589
1608
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
@@ -1604,6 +1623,7 @@ ${consoleShellScript}
1604
1623
  if (currentName !== name) return;
1605
1624
  if (!r.ok) throw new Error(r.body.error || 'request failed');
1606
1625
  var tasks = r.body.tasks || [];
1626
+ schedulesTimeZone = r.body.time_zone || null;
1607
1627
  content.innerHTML = renderSchedulesHtml(tasks);
1608
1628
  wireSchedulesHandlers(name);
1609
1629
  leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
@@ -2034,7 +2054,7 @@ ${consoleShellScript}
2034
2054
  '<input type="text" name="q" placeholder="Search by name or keyword (optional)">' +
2035
2055
  '<button type="submit">Search</button>' +
2036
2056
  '</form>' +
2037
- '<div id="abilitySearchResults"></div>' +
2057
+ '<div id="abilitySearchResults" style="margin-bottom:32px"></div>' +
2038
2058
  '<h3 style="margin-top:24px">Install from a spec</h3>' +
2039
2059
  '<p class="hint">A git URL, a local <code>file:../path</code>, or a private npm package name/version — for anything not published publicly.</p>' +
2040
2060
  '<form id="abilityInstallForm" class="add-source">' +
@@ -2517,8 +2537,10 @@ ${consoleShellScript}
2517
2537
  '<section><h3>Your tools (' + cfg.localTools.length + ')</h3>' + renderLocalTools(cfg.localTools) +
2518
2538
  '<div class="admin-subsection"><h4 id="httpToolFormHeading">Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
2519
2539
  '</section>' +
2520
- '<section id="gatewayToolsSection">' +
2521
- '<h3>From integrations <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
2540
+ // Shown only for an agent that already has Composio tools
2541
+ // (gateway-tools.yml): see applyGatewaySources.
2542
+ '<section id="gatewayToolsSection" style="display:none">' +
2543
+ '<h3>From Composio <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
2522
2544
  '<div id="gatewayToolsContent"><p class="hint">Loading&hellip;</p></div>' +
2523
2545
  '</section>' +
2524
2546
  '<section><h3>Subagents (' + cfg.agentAsTools.length + ')</h3>' + renderTools(cfg.agentAsTools) + '</section>';
@@ -3062,6 +3084,8 @@ ${consoleShellScript}
3062
3084
  function applyGatewaySources(name, sources) {
3063
3085
  var content = gatewayContentEl();
3064
3086
  if (!content) return;
3087
+ var section = detail.querySelector('#gatewayToolsSection');
3088
+ if (section) section.style.display = sources.length ? '' : 'none';
3065
3089
  content.innerHTML = renderGatewayHtml(sources);
3066
3090
  wireGatewayHandlers(name);
3067
3091
  gatewayLoadedFor = name;
@@ -1,5 +1,5 @@
1
1
  // The app shell every console page renders inside: a sidebar listing the
2
- // agents (plus Models and Integrations), and, for a page about one agent,
2
+ // agents (plus Models, Abilities and Settings), and, for a page about one agent,
3
3
  // a header with its name, prompt and tabs. The tabs span two pages —
4
4
  // Chat is playground.ts, every other tab is agents-config-page.ts — so
5
5
  // both render the header through this one script and read as one app.
@@ -22,11 +22,11 @@ export const consoleSidebarHtml = `<aside class="le-sidebar" aria-label="Console
22
22
  <a class="le-brand" href="/agents"><span class="le-brand-mark">${BRAND_MARK}</span><span class="le-brand-name">LoopEngine</span></a>
23
23
  <div class="le-nav-label"><span>Agents</span><a href="/agents?new=1">+ New</a></div>
24
24
  <ul class="le-agent-list" id="leAgentList"></ul>
25
- <a class="le-find" href="/templates" data-le-nav="templates">Start from a template</a>
26
25
  <a class="le-find" href="/interview" data-le-nav="interview">Find your first agent</a>
26
+ <a class="le-find" href="/templates" data-le-nav="templates">Start from a template</a>
27
27
  <nav class="le-foot" aria-label="Settings">
28
28
  <a href="/config?section=models" data-le-nav="models">Models</a>
29
- <a href="/config?section=gateways" data-le-nav="integrations">Integrations</a>
29
+ <a href="/config?section=abilities" data-le-nav="abilities">Abilities</a>
30
30
  <a href="/config?section=settings" data-le-nav="settings">Settings</a>
31
31
  <div class="le-who" id="leWho" hidden></div>
32
32
  </nav>
@@ -161,7 +161,7 @@ export const consoleShellScript = `<script>
161
161
  }
162
162
 
163
163
  var section = params.get('section');
164
- var foot = page === 'config' ? (section === 'gateways' ? 'integrations' : section === 'settings' ? 'settings' : 'models') : page === 'interview' || page === 'templates' ? page : '';
164
+ var foot = page === 'config' ? (section === 'abilities' || section === 'gateways' ? 'abilities' : section === 'settings' ? 'settings' : 'models') : page === 'interview' || page === 'templates' ? page : '';
165
165
  var footLinks = document.querySelectorAll('[data-le-nav]');
166
166
  for (var i = 0; i < footLinks.length; i++) {
167
167
  if (footLinks[i].getAttribute('data-le-nav') === foot) footLinks[i].setAttribute('aria-current', 'page');
@@ -1,6 +1,6 @@
1
1
  // Shared look for every browser page adapters/http.ts serves — the agent
2
2
  // console (agents-config-page.ts), its Chat tab (playground.ts), the
3
- // agents home (agents-list-page.ts) and the Models/Integrations pages
3
+ // agents home (agents-list-page.ts) and the Models/Abilities pages
4
4
  // (global-config-page.ts). All four render inside the same app shell
5
5
  // (web/console-shell.ts), so the shell's own classes (.le-*) live here
6
6
  // too, alongside the shared base: color tokens, type, form controls,
@@ -4,9 +4,9 @@
4
4
  // Overview/Skills/Tools/ActAuth tabs (served separately at
5
5
  // /agents/config, deliberately left untouched by this page). A left
6
6
  // sidebar picks between Models (which providers this deployment can
7
- // call, and which agents use which) and Gateways (whether the composio
8
- // CLI is authenticated on this machine) — see global-config.ts for where
9
- // that data actually comes from. "Agents" is a plain link out to
7
+ // call, and which agents use which), Abilities (what each agent has
8
+ // installed, and adding more) and Settings — see global-config.ts for
9
+ // where most of that data comes from. "Agents" is a plain link out to
10
10
  // /agents/config, not a third client-rendered section here: that page
11
11
  // already owns the full per-agent picture, duplicating or embedding it
12
12
  // would just be two places for the same UI to drift apart.
@@ -78,10 +78,10 @@ ${consoleSidebarHtml}
78
78
  <div><h1>Models</h1><p>API keys for each model provider, and which agents use each one. A saved key is stored on this server and never shown again.</p></div></div></div>
79
79
  <div class="le-scroll"><div class="le-page" style="display:block"><div id="modelsContent"><p class="hint">Loading&hellip;</p></div></div></div>
80
80
  </div>
81
- <div class="section-panel" data-section-panel="gateways" style="display:none">
81
+ <div class="section-panel" data-section-panel="abilities" style="display:none">
82
82
  <div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">&#9776;</button>
83
- <div><h1>Integrations</h1><p>Outside tool sources (such as Composio) that agents can add tools from.</p></div></div></div>
84
- <div class="le-scroll"><div class="le-page" style="display:block"><div id="gatewaysContent"><p class="hint">Loading&hellip;</p></div></div></div>
83
+ <div><h1>Abilities</h1><p>How agents reach your apps, such as Gmail, Shopify or GitHub. An ability installed into an agent adds its tools, skills and permission rules.</p></div></div></div>
84
+ <div class="le-scroll"><div class="le-page" style="display:block"><div id="abilitiesContent"><p class="hint">Loading&hellip;</p></div></div></div>
85
85
  </div>
86
86
  <div class="section-panel" data-section-panel="settings" style="display:none">
87
87
  <div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">&#9776;</button>
@@ -202,83 +202,150 @@ ${consoleShellScript}
202
202
  });
203
203
  }
204
204
 
205
- // ---- Gateways section ----
206
-
207
- function renderGatewayCard(g) {
208
- var statusHtml;
209
- var bodyHtml = '';
210
- if (!g.supported) {
211
- statusHtml = '<span class="hint">not yet supported</span>';
212
- } else if (g.connected) {
213
- statusHtml = badge(true, 'connected', 'not connected');
214
- bodyHtml = '<p class="hint">' + escapeHtml(g.email || '') + (g.org ? ' &middot; ' + escapeHtml(g.org) : '') + '</p>' +
215
- '<button type="button" class="delete-btn disconnect-btn" data-provider="' + escapeHtml(g.provider) + '">Disconnect</button>';
216
- } else {
217
- // No Connect button/form here — composio login's only
218
- // non-interactive path (--user-api-key) doesn't accept the kind of
219
- // key an operator actually has on hand (Composio hands out a
220
- // separate x-consumer-api-key credential for MCP clients, which
221
- // 401s against this flag); composio login itself opens a real
222
- // browser, which a web request can't drive. These are the same
223
- // two commands Composio's own docs give for getting started.
224
- statusHtml = badge(false, 'connected', 'not connected');
225
- bodyHtml = '<pre><code>curl -fsSL https://composio.dev/install | sh</code></pre>' +
226
- '<pre><code>composio login</code></pre>' +
227
- '<p class="hint">Run both on the machine running this server, then reload this page.</p>';
205
+ // ---- Abilities section ----
206
+ // How agents reach outside apps: an ability (lp-gmail, lp-shopify...)
207
+ // installed into an agent brings its tools, skills and permission
208
+ // rules. Abilities live in each agent, so this page gathers them from
209
+ // every agent's own Abilities routes and installs through the same
210
+ // route, which also keeps it working with any project template.
211
+
212
+ var abilityAgents = [];
213
+ var abilitiesByAgent = {};
214
+
215
+ function agentAbilitiesHref(agent) {
216
+ return '/agents/config?agent=' + encodeURIComponent(agent) + '&tab=abilities';
217
+ }
218
+
219
+ function renderInstalledAbilities() {
220
+ var byName = {};
221
+ abilityAgents.forEach(function (agent) {
222
+ (abilitiesByAgent[agent] || []).forEach(function (a) {
223
+ if (!byName[a.name]) byName[a.name] = { name: a.name, versions: [], agents: [] };
224
+ if (byName[a.name].versions.indexOf(a.version) === -1) byName[a.name].versions.push(a.version);
225
+ byName[a.name].agents.push(agent);
226
+ });
227
+ });
228
+ var names = Object.keys(byName).sort();
229
+ if (!names.length) return '<p class="hint">No agent has an ability yet. Add one below, or start from a template, which installs the ones it needs.</p>';
230
+ return '<table><thead><tr><th>Ability</th><th>Version</th><th>Agents</th></tr></thead><tbody>' +
231
+ names.map(function (n) {
232
+ var a = byName[n];
233
+ return '<tr><td><code>' + escapeHtml(a.name) + '</code></td>' +
234
+ '<td>' + escapeHtml(a.versions.join(', ')) + '</td>' +
235
+ '<td>' + a.agents.map(function (agent) { return '<a href="' + agentAbilitiesHref(agent) + '">' + escapeHtml(agent) + '</a>'; }).join(', ') + '</td></tr>';
236
+ }).join('') + '</tbody></table>';
237
+ }
238
+
239
+ function renderAbilities() {
240
+ var content = document.getElementById('abilitiesContent');
241
+ var add = abilityAgents.length
242
+ ? '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px">' +
243
+ '<label for="abilityAgent">Install into</label>' +
244
+ '<select id="abilityAgent">' + abilityAgents.map(function (a) { return '<option value="' + escapeHtml(a) + '">' + escapeHtml(a) + '</option>'; }).join('') + '</select>' +
245
+ '<form id="abilitySearchForm" style="display:flex;gap:6px;flex:1;min-width:220px;margin:0">' +
246
+ '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search abilities" style="flex:1">' +
247
+ '<button type="submit">Search</button>' +
248
+ '</form>' +
249
+ '</div>' +
250
+ '<p id="abilityInstallStatus" class="hint"></p>' +
251
+ '<div id="abilitySearchResults"><p class="hint">Loading&hellip;</p></div>'
252
+ : '<p>Create an agent first: abilities are installed into one. <a href="/templates">Start from a template</a> or <a href="/agents?new=1">create an agent</a>.</p>';
253
+ content.innerHTML =
254
+ '<section><h3>Installed</h3><div id="abilitiesInstalled">' + renderInstalledAbilities() + '</div></section>' +
255
+ '<section><h3>Add an ability</h3>' + add + '</section>';
256
+ if (!abilityAgents.length) return;
257
+ var form = document.getElementById('abilitySearchForm');
258
+ form.addEventListener('submit', function (ev) { ev.preventDefault(); searchAbilities(form.q.value.trim()); });
259
+ document.getElementById('abilityAgent').addEventListener('change', function () { searchAbilities(form.q.value.trim()); });
260
+ searchAbilities('');
261
+ }
262
+
263
+ var lastSearch = { q: null, results: [] };
264
+
265
+ function renderSearchResults() {
266
+ var resultsEl = document.getElementById('abilitySearchResults');
267
+ var agent = document.getElementById('abilityAgent').value;
268
+ var installed = (abilitiesByAgent[agent] || []).map(function (a) { return a.name; });
269
+ var results = lastSearch.results;
270
+ resultsEl.innerHTML = results.length
271
+ ? '<table><thead><tr><th>Ability</th><th>What it does</th><th></th></tr></thead><tbody>' + results.map(function (pkg) {
272
+ var action = installed.indexOf(pkg.name) !== -1
273
+ ? '<span class="hint">In ' + escapeHtml(agent) + '</span>'
274
+ : '<button type="button" class="ability-install-btn" data-spec="' + escapeHtml(pkg.name) + '">Install</button>';
275
+ return '<tr><td><code>' + escapeHtml(pkg.name) + '</code> <span class="hint">' + escapeHtml(pkg.version) + '</span></td>' +
276
+ '<td>' + escapeHtml(pkg.description || '') + '</td><td>' + action + '</td></tr>';
277
+ }).join('') + '</tbody></table>'
278
+ : '<p class="hint">No published abilities match.</p>';
279
+ var buttons = resultsEl.querySelectorAll('.ability-install-btn');
280
+ for (var i = 0; i < buttons.length; i++) {
281
+ buttons[i].addEventListener('click', function (ev) { installAbility(agent, ev.currentTarget.dataset.spec, ev.currentTarget); });
228
282
  }
229
- return '<div class="source' + (g.supported ? '' : ' unsupported') + '">' +
230
- '<div class="source-head">' +
231
- '<h4>' + escapeHtml(g.provider) + '</h4>' +
232
- statusHtml +
233
- '</div>' +
234
- bodyHtml +
235
- '</div>';
236
283
  }
237
284
 
238
- function applyGateways(gateways) {
239
- var content = document.getElementById('gatewaysContent');
240
- content.innerHTML = (gateways || []).map(renderGatewayCard).join('');
241
- wireGatewayCards(content);
242
- loadedSections.gateways = true;
285
+ function searchAbilities(q) {
286
+ var resultsEl = document.getElementById('abilitySearchResults');
287
+ if (lastSearch.q === q) { renderSearchResults(); return; }
288
+ resultsEl.innerHTML = '<p class="hint">Loading&hellip;</p>';
289
+ // Search doesn't depend on the agent; the route just lives under one.
290
+ fetch('/agents/' + encodeURIComponent(abilityAgents[0]) + '/abilities/search' + (q ? '?q=' + encodeURIComponent(q) : ''))
291
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
292
+ .then(function (r) {
293
+ if (!r.ok) throw new Error(r.body.error || 'search failed');
294
+ lastSearch = { q: q, results: r.body.results || [] };
295
+ renderSearchResults();
296
+ })
297
+ .catch(function (err) { resultsEl.innerHTML = '<p class="error">Could not search abilities: ' + escapeHtml(err.message) + '</p>'; });
243
298
  }
244
299
 
245
- // Disconnect is a machine-wide CLI-session action (see
246
- // disconnectComposioAccount's own doc comment in gateway-tools.ts) —
247
- // it gets a confirm() for that reason, same as this admin UI's other
248
- // consequential-but-not-undoable actions (removing a skill, a rule, a
249
- // gateway tool). No Connect counterpart to wire — see renderGatewayCard's
250
- // own comment for why that's instructions, not a form.
251
- function wireGatewayCards(content) {
252
- var disconnectButtons = content.querySelectorAll('.disconnect-btn');
253
- for (var j = 0; j < disconnectButtons.length; j++) {
254
- disconnectButtons[j].addEventListener('click', function (ev) {
255
- var btn = ev.currentTarget;
256
- var provider = btn.getAttribute('data-provider');
257
- if (!confirm('Disconnect ' + provider + '? This logs out the CLI on this machine for every agent’s gateway tools, not just this browser tab.')) return;
258
- btn.disabled = true;
259
- fetch('/config/gateways/' + encodeURIComponent(provider) + '/disconnect', { method: 'POST' })
260
- .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
261
- .then(function (result) {
262
- if (!result.ok) throw new Error(result.body.error || 'request failed');
263
- applyGateways(result.body.gateways || []);
264
- })
265
- .catch(function (err) {
266
- alert('Could not disconnect: ' + err.message);
267
- btn.disabled = false;
268
- });
300
+ function installAbility(agent, spec, btn) {
301
+ var status = document.getElementById('abilityInstallStatus');
302
+ btn.disabled = true;
303
+ status.className = 'hint';
304
+ status.textContent = 'Installing ' + spec + ' into ' + agent + String.fromCharCode(8230);
305
+ fetch('/agents/' + encodeURIComponent(agent) + '/abilities', {
306
+ method: 'POST',
307
+ headers: { 'content-type': 'application/json' },
308
+ body: JSON.stringify({ spec: spec }),
309
+ })
310
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
311
+ .then(function (r) {
312
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
313
+ var deps = r.body.dependencies || [];
314
+ status.innerHTML = 'Installed ' + escapeHtml(spec) + ' into ' + escapeHtml(agent) + '. ' +
315
+ '<a href="' + agentAbilitiesHref(agent) + '">Add its keys in ' + escapeHtml(agent) + String.fromCharCode(8217) + 's Abilities tab</a>.' +
316
+ (deps.length ? ' <strong>It also needs <code>' + escapeHtml(deps.join(', ')) + '</code>:</strong> use Install deps there.' : '');
317
+ return loadAgentAbilities(agent).then(function () {
318
+ document.getElementById('abilitiesInstalled').innerHTML = renderInstalledAbilities();
319
+ renderSearchResults();
320
+ });
321
+ })
322
+ .catch(function (err) {
323
+ btn.disabled = false;
324
+ status.className = 'error';
325
+ status.textContent = 'Could not install: ' + err.message;
269
326
  });
270
- }
271
327
  }
272
328
 
273
- function loadGateways() {
274
- var content = document.getElementById('gatewaysContent');
275
- fetch('/config/gateways')
276
- .then(function (r) { return r.json(); })
277
- .then(function (data) {
278
- applyGateways(data.gateways || []);
329
+ function loadAgentAbilities(agent) {
330
+ return fetch('/agents/' + encodeURIComponent(agent) + '/abilities')
331
+ .then(function (r) { return r.ok ? r.json() : { abilities: [] }; })
332
+ .then(function (d) { abilitiesByAgent[agent] = d.abilities || []; })
333
+ .catch(function () { abilitiesByAgent[agent] = []; });
334
+ }
335
+
336
+ function loadAbilities() {
337
+ var content = document.getElementById('abilitiesContent');
338
+ leShell.ready
339
+ .then(function (agents) {
340
+ abilityAgents = agents.map(function (a) { return a.name; });
341
+ return Promise.all(abilityAgents.map(loadAgentAbilities));
342
+ })
343
+ .then(function () {
344
+ renderAbilities();
345
+ loadedSections.abilities = true;
279
346
  })
280
347
  .catch(function (err) {
281
- content.innerHTML = '<p class="error">Could not load gateways: ' + escapeHtml(err.message) + '</p>';
348
+ content.innerHTML = '<p class="error">Could not load abilities: ' + escapeHtml(err.message) + '</p>';
282
349
  });
283
350
  }
284
351
 
@@ -298,14 +365,78 @@ ${consoleShellScript}
298
365
  ['Environment name', '<code>' + escapeHtml(d.environment) + '</code> <span class="hint">(LOOPENGINE_ENV)</span>'],
299
366
  ['Public address', d.publicUrl ? '<code>' + escapeHtml(d.publicUrl) + '</code>' : '<span class="hint">Not set (LOOPENGINE_PUBLIC_URL); links in notifications use the address saved with each notifier</span>'],
300
367
  ['Scheduled tasks', d.scheduler === 'off' ? 'Off on this server (LOOPENGINE_SCHEDULER=off)' : 'Run by this server'],
368
+ ['Time zone', d.timeZone ? '<code>' + escapeHtml(d.timeZone) + '</code>' : '<span class="hint">The server' + String.fromCharCode(8217) + 's own. Update the template below to choose one here.</span>'],
301
369
  ['Theme', 'Follows your system'],
302
370
  ];
303
371
  return '<section><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody>' +
304
372
  rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
305
373
  '</tbody></table></section>' +
374
+ (d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
306
375
  '<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
307
376
  }
308
377
 
378
+ // ---- Time zone ----
379
+
380
+ function renderTimeZone(t) {
381
+ var box = document.getElementById('timeZoneBox');
382
+ if (!box) return;
383
+ var intro = '<p>Schedules and templates use this for times of day: &ldquo;daily at 09:00&rdquo; runs at 9:00 here, and daily and weekly schedules keep that time when the clocks change. ' +
384
+ (t.chosen ? '' : 'Not chosen yet, so it' + String.fromCharCode(8217) + 's the server' + String.fromCharCode(8217) + 's (<code>' + escapeHtml(t.server) + '</code>).') + '</p>';
385
+ if (!t.canSave) {
386
+ box.innerHTML = intro + '<p>In use: <code>' + escapeHtml(t.timeZone) + '</code></p><p class="hint">Set LOOPENGINE_ADMIN_AUTH to change it here, or put <code>LOOPENGINE_TIME_ZONE</code> in .env.</p>';
387
+ return;
388
+ }
389
+ var browserZone = '';
390
+ try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
391
+ var options = t.zones.map(function (z) {
392
+ return '<option value="' + escapeHtml(z) + '"' + (z === t.timeZone ? ' selected' : '') + '>' + escapeHtml(z.replace(/_/g, ' ')) + '</option>';
393
+ }).join('');
394
+ box.innerHTML = intro +
395
+ '<form id="timeZoneForm" style="display:flex;flex-direction:column;gap:10px;max-width:480px">' +
396
+ '<label for="timeZoneSelect">Time zone</label>' +
397
+ '<select id="timeZoneSelect" name="timeZone">' + options + '</select>' +
398
+ (browserZone && browserZone !== t.timeZone && t.zones.indexOf(browserZone) >= 0
399
+ ? '<p class="hint" style="margin:0"><a href="#" id="timeZoneUseBrowser">Use this browser' + String.fromCharCode(8217) + 's, ' + escapeHtml(browserZone) + '</a></p>' : '') +
400
+ '<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="moveSchedules" checked> Move existing daily and weekly schedules too, keeping their time of day</label>' +
401
+ '<div><button type="submit" class="primary">Save</button></div>' +
402
+ '<div id="timeZoneMsg" class="hint"></div>' +
403
+ '</form>';
404
+ var form = document.getElementById('timeZoneForm');
405
+ var useBrowser = document.getElementById('timeZoneUseBrowser');
406
+ if (useBrowser) useBrowser.addEventListener('click', function (ev) { ev.preventDefault(); form.timeZone.value = browserZone; });
407
+ form.addEventListener('submit', function (ev) {
408
+ ev.preventDefault();
409
+ var msg = document.getElementById('timeZoneMsg');
410
+ var btn = form.querySelector('button[type="submit"]');
411
+ btn.disabled = true;
412
+ msg.textContent = 'Saving' + String.fromCharCode(8230);
413
+ fetch('/config/time-zone', {
414
+ method: 'PUT',
415
+ headers: { 'content-type': 'application/json' },
416
+ body: JSON.stringify({ timeZone: form.timeZone.value, moveSchedules: form.moveSchedules.checked }),
417
+ })
418
+ .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
419
+ .then(function (r) {
420
+ btn.disabled = false;
421
+ if (!r.ok) { msg.innerHTML = '<span class="error">' + escapeHtml(r.body.error || 'Could not save') + '</span>'; return; }
422
+ msg.textContent = 'Saved: ' + r.body.timeZone.replace(/_/g, ' ') + '.' +
423
+ (r.body.moved ? ' Moved ' + r.body.moved + ' schedule' + (r.body.moved === 1 ? '' : 's') + '.' : '');
424
+ })
425
+ .catch(function (err) { btn.disabled = false; msg.innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'; });
426
+ });
427
+ }
428
+
429
+ function loadTimeZone() {
430
+ if (!document.getElementById('timeZoneBox')) return;
431
+ fetch('/config/time-zone', { cache: 'no-store' })
432
+ .then(function (r) { return r.json(); })
433
+ .then(renderTimeZone)
434
+ .catch(function (err) {
435
+ var box = document.getElementById('timeZoneBox');
436
+ if (box) box.innerHTML = '<p class="error">Could not load the time zone: ' + escapeHtml(err.message) + '</p>';
437
+ });
438
+ }
439
+
309
440
  // ---- Upgrade ----
310
441
 
311
442
  var UPGRADE_NOTE = 'Updates this server' + String.fromCharCode(8217) + 's LoopEngine code and restarts it, which takes a minute or two. ' +
@@ -436,6 +567,7 @@ ${consoleShellScript}
436
567
  .then(function (d) {
437
568
  content.innerHTML = renderSettings(d);
438
569
  loadedSections.settings = true;
570
+ loadTimeZone();
439
571
  loadUpgrade();
440
572
  })
441
573
  .catch(function (err) {
@@ -455,7 +587,7 @@ ${consoleShellScript}
455
587
  panels[j].style.display = panels[j].dataset.sectionPanel === section ? 'contents' : 'none';
456
588
  }
457
589
  if (section === 'models' && !loadedSections.models) loadModels();
458
- if (section === 'gateways' && !loadedSections.gateways) loadGateways();
590
+ if (section === 'abilities' && !loadedSections.abilities) loadAbilities();
459
591
  if (section === 'settings' && !loadedSections.settings) loadSettings();
460
592
  }
461
593
 
@@ -463,12 +595,14 @@ ${consoleShellScript}
463
595
  sectionButtons[i].addEventListener('click', function (ev) { switchSection(ev.currentTarget.dataset.section); });
464
596
  }
465
597
 
466
- // Deep-linked from agents-config-page.ts's own Models/Gateways sidebar
467
- // links (/config?section=gateways, say) — preselect it if it names a
598
+ // Deep-linked from the console sidebar's Models/Abilities/Settings
599
+ // links (/config?section=abilities, say) — preselect it if it names a
468
600
  // real section, same "trust the query param only if it's valid"
469
601
  // caution playground.ts's own ?agent= handling already uses.
470
602
  var requestedSection = new URLSearchParams(location.search).get('section');
471
- var initialSection = requestedSection === 'gateways' || requestedSection === 'models' || requestedSection === 'settings' ? requestedSection : currentSection;
603
+ // ?section=gateways is the old Integrations page's link.
604
+ if (requestedSection === 'gateways') requestedSection = 'abilities';
605
+ var initialSection = requestedSection === 'abilities' || requestedSection === 'models' || requestedSection === 'settings' ? requestedSection : currentSection;
472
606
  switchSection(initialSection);
473
607
  })();
474
608
  </script>
@@ -69,3 +69,27 @@ export declare function modelKeyEnvVar(provider: string): string | undefined;
69
69
  */
70
70
  export declare function setModelProviderKey(provider: string, key: unknown): Promise<void>;
71
71
  export declare function clearModelProviderKey(provider: string): Promise<void>;
72
+ export declare class TimeZoneError extends Error {
73
+ }
74
+ export interface TimeZoneView {
75
+ /** The one in use. */
76
+ timeZone: string;
77
+ /** Whether it was chosen (LOOPENGINE_TIME_ZONE), not the server's. */
78
+ chosen: boolean;
79
+ server: string;
80
+ zones: string[];
81
+ }
82
+ export declare function describeTimeZone(): TimeZoneView;
83
+ /**
84
+ * The console's Settings → Time zone: saved like a model key (the
85
+ * project's .env by default) and applied to this process at once. New
86
+ * schedules and template start times use it from then on; with
87
+ * `moveSchedules`, existing daily and weekly schedules move to it too,
88
+ * keeping their time of day. An empty value goes back to the server's.
89
+ */
90
+ export declare function setWorkspaceTimeZone(value: unknown, options?: {
91
+ moveSchedules?: boolean;
92
+ }): Promise<{
93
+ timeZone: string;
94
+ moved: number;
95
+ }>;
@@ -1,4 +1,6 @@
1
1
  import { getComposioAuthStatus } from '../core/gateway-tools.js';
2
+ import { moveScheduledTasksToTimeZone } from '../core/scheduler.js';
3
+ import { TIME_ZONE_ENV, isValidTimeZone, listTimeZones, serverTimeZone, workspaceTimeZone } from '../core/time-zone.js';
2
4
  import { setEnvVar, unsetEnvVar } from './env-admin.js';
3
5
  export class ModelKeyError extends Error {
4
6
  }
@@ -102,3 +104,31 @@ export async function clearModelProviderKey(provider) {
102
104
  throw new ModelKeyError(`unknown provider '${provider}'`);
103
105
  await unsetEnvVar(envVar);
104
106
  }
107
+ export class TimeZoneError extends Error {
108
+ }
109
+ export function describeTimeZone() {
110
+ const timeZone = workspaceTimeZone();
111
+ const configured = process.env[TIME_ZONE_ENV]?.trim();
112
+ return { timeZone, chosen: Boolean(configured && isValidTimeZone(configured)), server: serverTimeZone(), zones: listTimeZones() };
113
+ }
114
+ /**
115
+ * The console's Settings → Time zone: saved like a model key (the
116
+ * project's .env by default) and applied to this process at once. New
117
+ * schedules and template start times use it from then on; with
118
+ * `moveSchedules`, existing daily and weekly schedules move to it too,
119
+ * keeping their time of day. An empty value goes back to the server's.
120
+ */
121
+ export async function setWorkspaceTimeZone(value, options = {}) {
122
+ const requested = typeof value === 'string' ? value.trim() : '';
123
+ if (requested && !isValidTimeZone(requested))
124
+ throw new TimeZoneError(`"${requested}" isn't a time zone this server knows, such as America/New_York or Europe/London`);
125
+ const before = workspaceTimeZone();
126
+ // Saved by its canonical name ("america/new_york" → "America/New_York").
127
+ if (requested)
128
+ await setEnvVar(TIME_ZONE_ENV, new Intl.DateTimeFormat('en-US', { timeZone: requested }).resolvedOptions().timeZone);
129
+ else
130
+ await unsetEnvVar(TIME_ZONE_ENV);
131
+ const timeZone = workspaceTimeZone();
132
+ const moved = options.moveSchedules && timeZone !== before ? await moveScheduledTasksToTimeZone(before, timeZone) : 0;
133
+ return { timeZone, moved };
134
+ }