loopengine 0.1.41 → 0.1.43

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.
@@ -63,6 +63,14 @@ export interface InstalledAbilityRecord {
63
63
  skills: string[];
64
64
  actauthRules: string[];
65
65
  env: AbilityEnvDecl[];
66
+ /** Real npm package names (dependencies + optionalDependencies) the
67
+ * ability's own tool files import, as of the last install/upgrade —
68
+ * same values AbilityManifest.dependencies carries, persisted here so
69
+ * the Admin UI's "Install dependencies" button can read them back
70
+ * without refetching the ability from its spec. Optional only because
71
+ * an ability installed before this field existed has no recorded
72
+ * value. */
73
+ dependencies?: string[];
66
74
  /** relative path (e.g. "tools/foo.ts") -> sha256 hex, as of the last
67
75
  * install/upgrade — remove-ability's dirty-check compares against
68
76
  * this rather than storing/refetching full content to diff. */
@@ -76,6 +84,20 @@ export interface InstalledAbilityRecord {
76
84
  export declare function listInstalledAbilities(agentName: string): Array<{
77
85
  name: string;
78
86
  } & InstalledAbilityRecord>;
87
+ /** `record.dependencies` is only ever absent for an ability installed or
88
+ * last upgraded before that field existed (see InstalledAbilityRecord's
89
+ * own doc comment) — this re-derives it once by refetching the ability's
90
+ * own manifest (same technique upgradeAbility already uses for its own
91
+ * three-way merge base) and persists the result into provenance, so this
92
+ * only ever runs once per such ability rather than on every read. A
93
+ * record that already has `dependencies` — even a real empty array,
94
+ * meaning the ability genuinely declares none — is returned unchanged,
95
+ * never refetched. Best-effort: a spec that no longer resolves (private
96
+ * registry auth expired, a git ref that's gone, ...) leaves
97
+ * `dependencies` unset rather than throwing, since the Admin UI's own
98
+ * caller (handleAbilitiesGet) would rather show "can't tell" than fail
99
+ * the whole abilities list over one ability's stale record. */
100
+ export declare function backfillDependencies(agentName: string, abilityName: string, options?: FetchOptions): Promise<string[] | undefined>;
79
101
  /** `npm pack <spec>` + tar extract — generalized from
80
102
  * create-loopengine's own `fetchPublishedTemplateDir` (that function
81
103
  * only ever fetches the hardcoded `create-loopengine@<version>`; this
@@ -166,6 +166,39 @@ export function listInstalledAbilities(agentName) {
166
166
  function writeProvenance(agentName, data) {
167
167
  writeFileSync(provenancePath(agentName), JSON.stringify(data, null, 2) + '\n');
168
168
  }
169
+ /** `record.dependencies` is only ever absent for an ability installed or
170
+ * last upgraded before that field existed (see InstalledAbilityRecord's
171
+ * own doc comment) — this re-derives it once by refetching the ability's
172
+ * own manifest (same technique upgradeAbility already uses for its own
173
+ * three-way merge base) and persists the result into provenance, so this
174
+ * only ever runs once per such ability rather than on every read. A
175
+ * record that already has `dependencies` — even a real empty array,
176
+ * meaning the ability genuinely declares none — is returned unchanged,
177
+ * never refetched. Best-effort: a spec that no longer resolves (private
178
+ * registry auth expired, a git ref that's gone, ...) leaves
179
+ * `dependencies` unset rather than throwing, since the Admin UI's own
180
+ * caller (handleAbilitiesGet) would rather show "can't tell" than fail
181
+ * the whole abilities list over one ability's stale record. */
182
+ export async function backfillDependencies(agentName, abilityName, options = {}) {
183
+ const provenance = readProvenance(agentName);
184
+ const record = provenance[abilityName];
185
+ if (!record)
186
+ return undefined;
187
+ if (record.dependencies !== undefined)
188
+ return record.dependencies;
189
+ const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
190
+ try {
191
+ const dir = fetch(record.spec ?? abilityName);
192
+ const manifest = readManifest(dir);
193
+ record.dependencies = manifest.dependencies ?? [];
194
+ provenance[abilityName] = record;
195
+ writeProvenance(agentName, provenance);
196
+ return record.dependencies;
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ }
169
202
  function sha256(content) {
170
203
  return createHash('sha256').update(content).digest('hex');
171
204
  }
@@ -454,6 +487,7 @@ export async function installAbility(agentName, spec, options = {}) {
454
487
  skills: skillIds,
455
488
  actauthRules: ruleNames,
456
489
  env: manifest.env ?? [],
490
+ dependencies: manifest.dependencies ?? [],
457
491
  contentHashes,
458
492
  };
459
493
  writeProvenance(agentName, provenance);
@@ -633,7 +667,14 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
633
667
  updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
634
668
  results.push({ path, status: 'updated' });
635
669
  }
636
- provenance[abilityName] = { ...record, version: newManifest.version, spec, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
670
+ provenance[abilityName] = {
671
+ ...record,
672
+ version: newManifest.version,
673
+ spec,
674
+ contentHashes: newContentHashes,
675
+ env: newManifest.env ?? record.env,
676
+ dependencies: newManifest.dependencies ?? record.dependencies,
677
+ };
637
678
  writeProvenance(agentName, provenance);
638
679
  return { files: results };
639
680
  }
package/dist/index.d.ts CHANGED
@@ -57,7 +57,7 @@ export { listDeclaredEnvVars, setEnvVar, EnvVarNameError } from './web/env-admin
57
57
  export type { DeclaredEnvVar } from './web/env-admin.js';
58
58
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
59
59
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
60
- export { installAbility, upgradeAbility, listInstalledAbilities, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
60
+ export { installAbility, upgradeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
61
61
  export type { InstalledAbilityRecord, AbilityEnvDecl, UpgradeFileResult } from './bin/ability-manager.js';
62
62
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
63
63
  export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ export { playgroundHtml } from './web/playground.js';
51
51
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
52
52
  export { listDeclaredEnvVars, setEnvVar, EnvVarNameError } from './web/env-admin.js';
53
53
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
54
- export { installAbility, upgradeAbility, listInstalledAbilities, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
54
+ export { installAbility, upgradeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
55
55
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
56
56
  export { describeModelProviders, describeGateways } from './web/global-config.js';
57
57
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
@@ -75,15 +75,18 @@ export const agentsConfigPageHtml = `<!doctype html>
75
75
  flex-direction: column;
76
76
  }
77
77
  #agentList { list-style: none; margin: 0; padding: 6px; flex: 1; }
78
- #agentList li {
78
+ #agentList li { border-radius: 6px; }
79
+ #agentList li a {
80
+ display: block;
79
81
  padding: 8px 10px;
80
82
  border-radius: 6px;
81
- cursor: pointer;
82
83
  font-size: 13px;
83
84
  font-family: ui-monospace, monospace;
85
+ color: inherit;
86
+ text-decoration: none;
84
87
  }
85
- #agentList li:hover { background: light-dark(#eee, #26262b); }
86
- #agentList li.active { background: light-dark(#dbeafe, #1e3a5f); font-weight: 600; }
88
+ #agentList li a:hover { background: light-dark(#eee, #26262b); }
89
+ #agentList li.active a { background: light-dark(#dbeafe, #1e3a5f); font-weight: 600; }
87
90
  main {
88
91
  flex: 1;
89
92
  overflow-y: auto;
@@ -1297,6 +1300,14 @@ export const agentsConfigPageHtml = `<!doctype html>
1297
1300
  var actionHtml = a.upToDate
1298
1301
  ? '<span class="hint">up to date</span>'
1299
1302
  : '<button type="button" class="ability-upgrade-btn" data-name="' + escapeHtml(a.name) + '">Upgrade</button>';
1303
+ // Same reasoning as upToDate above — missingDependencies (see
1304
+ // hasNodeModule) only ever hides this once the server actually
1305
+ // found every dependency already present in node_modules; anything
1306
+ // it can't confirm still shows the button.
1307
+ var deps = a.missingDependencies || [];
1308
+ if (deps.length) {
1309
+ actionHtml += ' <button type="button" class="ability-install-deps-btn" data-name="' + escapeHtml(a.name) + '" title="npm install ' + escapeHtml(deps.join(' ')) + '">Install deps</button>';
1310
+ }
1300
1311
  return '<tr>' +
1301
1312
  '<td><code>' + escapeHtml(a.name) + '</code></td>' +
1302
1313
  '<td>' + escapeHtml(a.version) + '</td>' +
@@ -1372,8 +1383,10 @@ export const agentsConfigPageHtml = `<!doctype html>
1372
1383
  // has to be installed separately, or the tool fails at runtime
1373
1384
  // with an opaque "Cannot find package" the very next time this
1374
1385
  // agent's config is loaded. Surfaced here, loudly, instead of
1375
- // leaving that as a silent trap discovered later.
1376
- message += ' <strong>This ability also needs: <code>npm install ' + escapeHtml(deps.join(' ')) + '</code></strong> in your own project before it will actually work.';
1386
+ // leaving that as a silent trap discovered later — the
1387
+ // Installed table below now has its own "Install deps" button
1388
+ // for exactly this (see handleAbilityInstallDepsPost).
1389
+ message += ' <strong>This ability also needs: <code>' + escapeHtml(deps.join(', ')) + '</code></strong> — use the "Install deps" button below, or run <code>npm install ' + escapeHtml(deps.join(' ')) + '</code> yourself.';
1377
1390
  }
1378
1391
  statusEl.innerHTML = message;
1379
1392
  loadAbilitiesTab(name);
@@ -1427,6 +1440,39 @@ export const agentsConfigPageHtml = `<!doctype html>
1427
1440
  });
1428
1441
  }
1429
1442
 
1443
+ // Runs npm install for an ability's own missing dependencies
1444
+ // (handleAbilityInstallDepsPost) — the server reads the dependency
1445
+ // list back off this ability's own installed record, not anything
1446
+ // this form could tamper with, so there's nothing to pass except
1447
+ // which ability. Can take a while (a native module like sharp
1448
+ // especially), so the button stays disabled and the status line says
1449
+ // so rather than looking hung.
1450
+ function installAbilityDeps(name, abilityName, statusEl, btn) {
1451
+ if (btn) { btn.disabled = true; }
1452
+ statusEl.className = 'hint';
1453
+ statusEl.textContent = 'Running npm install for ' + abilityName + '’s dependencies… this can take a minute.';
1454
+ fetch('/agents/' + encodeURIComponent(name) + '/abilities/' + encodeURIComponent(abilityName) + '/install-deps', {
1455
+ method: 'POST',
1456
+ headers: { 'content-type': 'application/json' },
1457
+ body: JSON.stringify({}),
1458
+ })
1459
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1460
+ .then(function (result) {
1461
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1462
+ var installed = result.body.installed || [];
1463
+ statusEl.className = 'hint';
1464
+ statusEl.textContent = installed.length
1465
+ ? 'Installed: ' + installed.join(', ') + '. Restart the server to pick it up.'
1466
+ : abilityName + ' had no dependencies to install.';
1467
+ loadAbilitiesTab(name);
1468
+ })
1469
+ .catch(function (err) {
1470
+ statusEl.className = 'error';
1471
+ statusEl.textContent = 'Could not install dependencies: ' + err.message;
1472
+ if (btn) { btn.disabled = false; }
1473
+ });
1474
+ }
1475
+
1430
1476
  // Shared by the initial (empty-query) load right after the tab opens
1431
1477
  // and the search form's own submit — the empty-query case is just
1432
1478
  // "list every published ability" (the keyword filter alone, no extra
@@ -1468,6 +1514,14 @@ export const agentsConfigPageHtml = `<!doctype html>
1468
1514
  });
1469
1515
  }
1470
1516
 
1517
+ var installDepsBtns = content.querySelectorAll('.ability-install-deps-btn');
1518
+ for (var d = 0; d < installDepsBtns.length; d++) {
1519
+ installDepsBtns[d].addEventListener('click', function (ev) {
1520
+ var btn = ev.currentTarget;
1521
+ installAbilityDeps(name, btn.dataset.name, upgradeStatusEl, btn);
1522
+ });
1523
+ }
1524
+
1471
1525
  var searchForm = content.querySelector('#abilitySearchForm');
1472
1526
  var resultsEl = content.querySelector('#abilitySearchResults');
1473
1527
  if (searchForm && resultsEl) {
@@ -2461,9 +2515,17 @@ export const agentsConfigPageHtml = `<!doctype html>
2461
2515
  for (var i = 0; i < agents.length; i++) {
2462
2516
  (function (name) {
2463
2517
  var li = document.createElement('li');
2464
- li.textContent = name;
2465
2518
  li.dataset.name = name;
2466
- li.addEventListener('click', function () { selectAgent(name); });
2519
+ // A real <a href>, not a click handler calling selectAgent —
2520
+ // switching agents is a full navigation to this same page with
2521
+ // a different ?agent=, so the URL, browser back/forward, and
2522
+ // bookmarking all reflect which agent is open, instead of the
2523
+ // URL staying frozen at whatever it was on first load while
2524
+ // the panel silently swaps underneath it via fetch.
2525
+ var a = document.createElement('a');
2526
+ a.href = '/agents/config?agent=' + encodeURIComponent(name);
2527
+ a.textContent = name;
2528
+ li.appendChild(a);
2467
2529
  agentList.appendChild(li);
2468
2530
  })(agents[i].name);
2469
2531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",