loopengine 0.1.74 → 0.1.76

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.
@@ -5,26 +5,6 @@ export interface AgentModule {
5
5
  /** Factory, not a shared instance — a fresh ModelCall per request/session. */
6
6
  createModelCall: () => ModelCall;
7
7
  }
8
- /** Builds a lazy, memoized createModelCall from AgentConfig.model, for a
9
- * module that doesn't export its own — see that field's own doc comment.
10
- * The provider's SDK module is imported here, eagerly, but that's cheap
11
- * and safe with no API key at all: importing model-calls/*.ts only
12
- * defines functions, it never constructs a client at module top level
13
- * (see e.g. createAnthropicModelCall's own body) — so this can't fail
14
- * discovery the way eagerly building the actual client would. The real
15
- * client is only built the first time the returned function is actually
16
- * called, and reused after that, the same "don't crash the whole server
17
- * over one agent's missing API key, don't rebuild per call" reasoning
18
- * agents/customer-service/index.ts's own hand-written createModelCall
19
- * used before this existed.
20
- *
21
- * Exported so a caller updating an *already-registered* agent's model
22
- * (see agent-file-admin.ts's editAgentFile, applied live via
23
- * core/agent-registry.ts's updateAgent) can rebuild just this one closure
24
- * without re-importing the whole module — Node's ESM loader caches an
25
- * already-imported module forever, so there's no supported way to
26
- * re-import a changed agent file's own createModelCall directly; this is
27
- * the one piece of it that's meaningfully regenerable on its own. */
28
8
  export declare function synthesizeCreateModelCall(model: AgentModelConfig): Promise<() => ModelCall>;
29
9
  /** Imports one agent module at `path` and resolves it to an AgentModule —
30
10
  * the single-file version of what discoverAgents does over a whole
@@ -37,30 +37,55 @@ function isRawAgentModule(mod) {
37
37
  * already-imported module forever, so there's no supported way to
38
38
  * re-import a changed agent file's own createModelCall directly; this is
39
39
  * the one piece of it that's meaningfully regenerable on its own. */
40
+ // The env var each provider's key defaults to (same names as
41
+ // web/global-config.ts's Models page).
42
+ const PROVIDER_KEY_ENV = {
43
+ anthropic: 'ANTHROPIC_API_KEY',
44
+ openai: 'OPENAI_API_KEY',
45
+ deepseek: 'DEEPSEEK_API_KEY',
46
+ kimi: 'MOONSHOT_API_KEY',
47
+ glm: 'GLM_API_KEY',
48
+ gemini: 'GEMINI_API_KEY',
49
+ };
40
50
  export async function synthesizeCreateModelCall(model) {
41
- let cached;
51
+ let build;
42
52
  if (model.provider === 'anthropic') {
43
53
  const { createAnthropicModelCall } = await import('./model-calls/anthropic-model-call.js');
44
- return () => (cached ??= createAnthropicModelCall(model));
54
+ build = () => createAnthropicModelCall(model);
45
55
  }
46
- if (model.provider === 'openai') {
56
+ else if (model.provider === 'openai') {
47
57
  const { createOpenAIModelCall } = await import('./model-calls/openai-model-call.js');
48
- return () => (cached ??= createOpenAIModelCall(model));
58
+ build = () => createOpenAIModelCall(model);
49
59
  }
50
- if (model.provider === 'deepseek') {
60
+ else if (model.provider === 'deepseek') {
51
61
  const { createDeepSeekModelCall } = await import('./model-calls/deepseek-model-call.js');
52
- return () => (cached ??= createDeepSeekModelCall(model));
62
+ build = () => createDeepSeekModelCall(model);
53
63
  }
54
- if (model.provider === 'kimi') {
64
+ else if (model.provider === 'kimi') {
55
65
  const { createKimiModelCall } = await import('./model-calls/kimi-model-call.js');
56
- return () => (cached ??= createKimiModelCall(model));
66
+ build = () => createKimiModelCall(model);
57
67
  }
58
- if (model.provider === 'glm') {
68
+ else if (model.provider === 'glm') {
59
69
  const { createGlmModelCall } = await import('./model-calls/glm-model-call.js');
60
- return () => (cached ??= createGlmModelCall(model));
70
+ build = () => createGlmModelCall(model);
71
+ }
72
+ else {
73
+ const { createGeminiModelCall } = await import('./model-calls/gemini-model-call.js');
74
+ build = () => createGeminiModelCall(model);
61
75
  }
62
- const { createGeminiModelCall } = await import('./model-calls/gemini-model-call.js');
63
- return () => (cached ??= createGeminiModelCall(model));
76
+ // Rebuilt when the key it was built with changes, so a key saved or
77
+ // replaced on the console's Models page applies to the next call
78
+ // without a restart. An explicit `apiKey` in the config never changes.
79
+ let cached;
80
+ let cachedKey;
81
+ return () => {
82
+ const key = model.apiKey ?? process.env[PROVIDER_KEY_ENV[model.provider]];
83
+ if (!cached || key !== cachedKey) {
84
+ cached = build();
85
+ cachedKey = key;
86
+ }
87
+ return cached;
88
+ };
64
89
  }
65
90
  /** Resolves one directory entry to an agent module's path, or undefined if
66
91
  * this entry isn't one — a `.ts`/`.js` file directly (the common case), or
@@ -4,6 +4,12 @@ export interface ProxyIdentity {
4
4
  sub: string;
5
5
  workspace?: string;
6
6
  role: string;
7
+ /** The person's email, when the proxy knows it (shown in the console's
8
+ * sidebar). */
9
+ email?: string;
10
+ /** Where the person manages this workspace on the host, shown as the
11
+ * console's "Dashboard" link. */
12
+ dashboardUrl?: string;
7
13
  /** Expiry, Unix seconds. */
8
14
  exp: number;
9
15
  }
@@ -0,0 +1 @@
1
+ export declare function loopengineVersion(): string;
@@ -0,0 +1,24 @@
1
+ // The installed loopengine version, for the console's Settings section.
2
+ // package.json sits one level above core/ in this repo, and two above
3
+ // dist/core/ in the published package (dist/package.json is only the
4
+ // module-resolution stub the build writes, with no version).
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ let cached;
7
+ export function loopengineVersion() {
8
+ if (cached)
9
+ return cached;
10
+ for (const rel of ['../package.json', '../../package.json']) {
11
+ const url = new URL(rel, import.meta.url);
12
+ if (!existsSync(url))
13
+ continue;
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(url, 'utf8'));
16
+ if (pkg.name === 'loopengine' && pkg.version)
17
+ return (cached = pkg.version);
18
+ }
19
+ catch {
20
+ // Not loopengine's package.json; try the next.
21
+ }
22
+ }
23
+ return (cached = 'unknown');
24
+ }
package/dist/index.d.ts CHANGED
@@ -78,6 +78,8 @@ export type { InstalledAbilityRecord, AbilityEnvDecl, UpgradeFileResult } from '
78
78
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
79
79
  export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
80
80
  export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
81
+ export { loopengineVersion } from './core/version.js';
82
+ export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
81
83
  export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView } from './web/global-config.js';
82
84
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
83
85
  export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
package/dist/index.js CHANGED
@@ -69,5 +69,7 @@ export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule
69
69
  export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
70
70
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
71
71
  export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
72
+ export { loopengineVersion } from './core/version.js';
73
+ export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
72
74
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
73
75
  export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
@@ -25,6 +25,8 @@ export const consoleSidebarHtml = `<aside class="le-sidebar" aria-label="Console
25
25
  <nav class="le-foot" aria-label="Settings">
26
26
  <a href="/config?section=models" data-le-nav="models">Models</a>
27
27
  <a href="/config?section=gateways" data-le-nav="integrations">Integrations</a>
28
+ <a href="/config?section=settings" data-le-nav="settings">Settings</a>
29
+ <div class="le-who" id="leWho" hidden></div>
28
30
  </nav>
29
31
  </aside>`;
30
32
  export const consoleShellScript = `<script>
@@ -156,12 +158,33 @@ export const consoleShellScript = `<script>
156
158
  renderList();
157
159
  }
158
160
 
159
- var foot = page === 'config' ? (params.get('section') === 'gateways' ? 'integrations' : 'models') : '';
161
+ var section = params.get('section');
162
+ var foot = page === 'config' ? (section === 'gateways' ? 'integrations' : section === 'settings' ? 'settings' : 'models') : '';
160
163
  var footLinks = document.querySelectorAll('[data-le-nav]');
161
164
  for (var i = 0; i < footLinks.length; i++) {
162
165
  if (footLinks[i].getAttribute('data-le-nav') === foot) footLinks[i].setAttribute('aria-current', 'page');
163
166
  }
164
167
 
168
+ // Who is signed in: the email and a link back to the dashboard when a
169
+ // hosting router signs requests, otherwise how this server is protected.
170
+ var who = document.getElementById('leWho');
171
+ if (who) {
172
+ fetch('/config/settings')
173
+ .then(function (r) { return r.ok ? r.json() : null; })
174
+ .then(function (s) {
175
+ if (!s || !s.signIn) return;
176
+ var si = s.signIn;
177
+ if (si.mode === 'hosted') {
178
+ who.innerHTML = '<span class="le-who-name" title="' + esc(si.email || '') + '">' + esc(si.email || 'Signed in') + '</span>' +
179
+ (si.dashboardUrl ? '<a href="' + esc(si.dashboardUrl) + '">Dashboard</a>' : '');
180
+ } else {
181
+ who.innerHTML = '<span class="le-who-name">' + (si.mode === 'basic' ? 'Signed in with Basic Auth' : 'No sign-in configured') + '</span>';
182
+ }
183
+ who.hidden = false;
184
+ })
185
+ .catch(function () {});
186
+ }
187
+
165
188
  document.addEventListener('click', function (ev) {
166
189
  if (!app) return;
167
190
  if (ev.target.closest('[data-le-menu]')) { app.classList.toggle('nav-open'); return; }
@@ -130,6 +130,11 @@ export const devUiCss = `
130
130
  .le-foot a { padding: 7px 8px; border-radius: 6px; color: var(--ink-muted); text-decoration: none; }
131
131
  .le-foot a:hover { background: var(--surface-2); color: var(--ink); }
132
132
  .le-foot a[aria-current="page"] { background: var(--accent-soft); color: var(--ink); }
133
+ .le-who { padding: 10px 8px 4px; font-size: 12px; color: var(--ink-faint); display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; }
134
+ .le-who[hidden] { display: none; }
135
+ .le-who-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
136
+ .le-foot .le-who a { padding: 0; flex: none; color: var(--accent-ink); }
137
+ .le-foot .le-who a:hover { background: none; text-decoration: underline; }
133
138
  .le-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
134
139
  .le-scroll { flex: 1; min-height: 0; overflow-y: auto; }
135
140
  .le-page { padding: 24px; max-width: 1100px; display: grid; gap: 24px; }
@@ -83,6 +83,11 @@ ${consoleSidebarHtml}
83
83
  <div><h1>Integrations</h1><p>Outside tool sources (such as Composio) that agents can add tools from.</p></div></div></div>
84
84
  <div class="le-scroll"><div class="le-page" style="display:block"><div id="gatewaysContent"><p class="hint">Loading&hellip;</p></div></div></div>
85
85
  </div>
86
+ <div class="section-panel" data-section-panel="settings" style="display:none">
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>
88
+ <div><h1>Settings</h1><p>How this server is set up. These come from its deployment, not from this page.</p></div></div></div>
89
+ <div class="le-scroll"><div class="le-page" style="display:block"><div id="settingsContent"><p class="hint">Loading&hellip;</p></div></div></div>
90
+ </div>
86
91
  </main>
87
92
  </div>
88
93
  ${consoleShellScript}
@@ -277,6 +282,142 @@ ${consoleShellScript}
277
282
  });
278
283
  }
279
284
 
285
+ // ---- Settings section ----
286
+
287
+ function renderSettings(d) {
288
+ var si = d.signIn || {};
289
+ var signIn = si.mode === 'hosted'
290
+ ? 'Managed by your LoopEngine Cloud account' + (si.email ? ' (' + escapeHtml(si.email) + ')' : '') +
291
+ (si.dashboardUrl ? ' &middot; <a href="' + escapeHtml(si.dashboardUrl) + '">Dashboard</a>' : '')
292
+ : si.mode === 'basic'
293
+ ? 'Basic Auth (<code>LOOPENGINE_ADMIN_AUTH</code>)'
294
+ : '<span class="error">None: anyone who can reach this server can use it. Set <code>LOOPENGINE_ADMIN_AUTH</code>.</span>';
295
+ var rows = [
296
+ ['Admin sign-in', signIn],
297
+ ['LoopEngine version', '<code>' + escapeHtml(d.version) + '</code>'],
298
+ ['Environment name', '<code>' + escapeHtml(d.environment) + '</code> <span class="hint">(LOOPENGINE_ENV)</span>'],
299
+ ['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
+ ['Scheduled tasks', d.scheduler === 'off' ? 'Off on this server (LOOPENGINE_SCHEDULER=off)' : 'Run by this server'],
301
+ ['Theme', 'Follows your system'],
302
+ ];
303
+ return '<section><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody>' +
304
+ rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
305
+ '</tbody></table></section>' +
306
+ '<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
307
+ }
308
+
309
+ // ---- Upgrade ----
310
+
311
+ var UPGRADE_NOTE = 'Updates this server' + String.fromCharCode(8217) + 's LoopEngine code and restarts it, which takes a minute or two. ' +
312
+ 'Your agents, abilities, conversations, approvals, schedules and settings are kept. ' +
313
+ 'A template file you edited (such as adapters/http.ts) is left exactly as it is.';
314
+
315
+ function upgradeBox() { return document.getElementById('upgradeBox'); }
316
+
317
+ function renderUpgrade(u) {
318
+ var box = upgradeBox();
319
+ if (!box) return;
320
+ if (u.unavailableReason && !u.updateAvailable) {
321
+ box.innerHTML = '<p class="hint">' + escapeHtml(u.unavailableReason) + '</p>';
322
+ } else if (!u.updateAvailable) {
323
+ box.innerHTML = '<p>You' + String.fromCharCode(8217) + 're on the latest version, <code>' + escapeHtml(u.current) + '</code>.</p>';
324
+ } else if (u.unavailableReason) {
325
+ box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p><p class="hint">' + escapeHtml(u.unavailableReason) + '</p>';
326
+ } else if (!u.canUpgrade) {
327
+ box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p><p class="hint">Set LOOPENGINE_ADMIN_AUTH to upgrade from here, or run <code>npx create-loopengine@latest upgrade</code>.</p>';
328
+ } else {
329
+ box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p>' +
330
+ '<p class="hint">' + UPGRADE_NOTE + (u.restartsItself ? '' : ' This server has nothing to restart it, so restart it yourself afterwards.') + '</p>' +
331
+ '<p><button type="button" class="primary" id="upgradeBtn">Upgrade to ' + escapeHtml(u.latest) + '</button></p>' +
332
+ '<div id="upgradeMsg" class="hint"></div>';
333
+ }
334
+ }
335
+
336
+ function loadUpgrade() {
337
+ fetch('/config/upgrade')
338
+ .then(function (r) { return r.json(); })
339
+ .then(renderUpgrade)
340
+ .catch(function (err) {
341
+ var box = upgradeBox();
342
+ if (box) box.innerHTML = '<p class="error">Could not check for updates: ' + escapeHtml(err.message) + '</p>';
343
+ });
344
+ }
345
+
346
+ function upgradeMsg(html, bad) {
347
+ var el = document.getElementById('upgradeMsg');
348
+ if (!el) return;
349
+ el.innerHTML = html;
350
+ el.className = bad ? 'error' : 'hint';
351
+ }
352
+
353
+ // After a successful upgrade the server restarts: wait for it to answer
354
+ // again on the new version, then reload so the page runs the new code.
355
+ function waitForRestart(target, tries) {
356
+ if (tries > 90) { upgradeMsg('The server hasn' + String.fromCharCode(8217) + 't come back yet. Reload this page in a minute.', true); return; }
357
+ setTimeout(function () {
358
+ fetch('/config/settings', { cache: 'no-store' })
359
+ .then(function (r) { return r.ok ? r.json() : null; })
360
+ .then(function (s) {
361
+ if (s && s.version === target) location.reload();
362
+ else waitForRestart(target, tries + 1);
363
+ })
364
+ .catch(function () { waitForRestart(target, tries + 1); });
365
+ }, 2000);
366
+ }
367
+
368
+ document.getElementById('settingsContent').addEventListener('click', function (ev) {
369
+ var btn = ev.target.closest('#upgradeBtn');
370
+ if (!btn) return;
371
+ if (btn.getAttribute('data-armed') !== '1') {
372
+ btn.setAttribute('data-armed', '1');
373
+ btn.textContent = 'Click again to upgrade';
374
+ return;
375
+ }
376
+ btn.disabled = true;
377
+ btn.textContent = 'Upgrading' + String.fromCharCode(8230);
378
+ upgradeMsg('Installing the new version. This takes a minute or two; keep this page open.', false);
379
+ fetch('/config/upgrade', { method: 'POST' })
380
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
381
+ .then(function (r) {
382
+ if (!r.ok) throw new Error(r.body.error || 'upgrade failed');
383
+ var o = r.body.outcome;
384
+ var changed = o.files.filter(function (f) { return f.status === 'updated'; }).map(function (f) { return f.file; });
385
+ var kept = o.files.filter(function (f) { return f.status === 'kept'; }).map(function (f) { return f.file; });
386
+ var deps = o.dependencies.map(function (d) { return d.name + ' ' + d.from + ' ' + String.fromCharCode(8594) + ' ' + d.to; });
387
+ var target = (o.dependencies.filter(function (d) { return d.name === 'loopengine'; })[0] || {}).to;
388
+ var summary = (deps.length ? 'Installed ' + escapeHtml(deps.join(', ')) + '. ' : '') +
389
+ (changed.length ? 'Updated ' + escapeHtml(changed.join(', ')) + '. ' : '') +
390
+ (kept.length ? 'Left as you edited them: ' + escapeHtml(kept.join(', ')) + '. ' : '');
391
+ btn.textContent = 'Upgraded';
392
+ if (r.body.restarting && target) {
393
+ upgradeMsg(summary + 'Restarting' + String.fromCharCode(8230), false);
394
+ waitForRestart(target.replace(/[^0-9.]/g, ''), 0);
395
+ } else {
396
+ upgradeMsg(summary + 'Restart the server to finish.', false);
397
+ }
398
+ })
399
+ .catch(function (err) {
400
+ btn.disabled = false;
401
+ btn.removeAttribute('data-armed');
402
+ btn.textContent = 'Try again';
403
+ upgradeMsg(escapeHtml(err.message) + ' Nothing was changed.', true);
404
+ });
405
+ });
406
+
407
+ function loadSettings() {
408
+ var content = document.getElementById('settingsContent');
409
+ fetch('/config/settings')
410
+ .then(function (r) { return r.json(); })
411
+ .then(function (d) {
412
+ content.innerHTML = renderSettings(d);
413
+ loadedSections.settings = true;
414
+ loadUpgrade();
415
+ })
416
+ .catch(function (err) {
417
+ content.innerHTML = '<p class="error">Could not load settings: ' + escapeHtml(err.message) + '</p>';
418
+ });
419
+ }
420
+
280
421
  // ---- Section switching ----
281
422
 
282
423
  function switchSection(section) {
@@ -290,6 +431,7 @@ ${consoleShellScript}
290
431
  }
291
432
  if (section === 'models' && !loadedSections.models) loadModels();
292
433
  if (section === 'gateways' && !loadedSections.gateways) loadGateways();
434
+ if (section === 'settings' && !loadedSections.settings) loadSettings();
293
435
  }
294
436
 
295
437
  for (var i = 0; i < sectionButtons.length; i++) {
@@ -301,7 +443,7 @@ ${consoleShellScript}
301
443
  // real section, same "trust the query param only if it's valid"
302
444
  // caution playground.ts's own ?agent= handling already uses.
303
445
  var requestedSection = new URLSearchParams(location.search).get('section');
304
- var initialSection = requestedSection === 'gateways' || requestedSection === 'models' ? requestedSection : currentSection;
446
+ var initialSection = requestedSection === 'gateways' || requestedSection === 'models' || requestedSection === 'settings' ? requestedSection : currentSection;
305
447
  switchSection(initialSection);
306
448
  })();
307
449
  </script>
@@ -0,0 +1,43 @@
1
+ export declare class UpgradeError extends Error {
2
+ }
3
+ export interface UpgradeStatus {
4
+ current: string;
5
+ /** Latest loopengine on npm; null when npm couldn't be reached. */
6
+ latest: string | null;
7
+ updateAvailable: boolean;
8
+ /** The create-loopengine version this project was scaffolded or last
9
+ * upgraded from (.create-loopengine.json); null when the project
10
+ * wasn't made by create-loopengine, which the button can't upgrade. */
11
+ templateVersion: string | null;
12
+ /** Why the button is off, when it is. */
13
+ unavailableReason?: string;
14
+ running: boolean;
15
+ }
16
+ export interface UpgradeOutcome {
17
+ fromVersion: string;
18
+ toVersion: string;
19
+ files: {
20
+ file: string;
21
+ status: string;
22
+ }[];
23
+ dependencies: {
24
+ name: string;
25
+ from: string;
26
+ to: string;
27
+ }[];
28
+ }
29
+ type Runner = (cmd: string, args: string[], cwd: string, timeoutMs: number) => Promise<string>;
30
+ /** Tests replace npm/npx with a fake. */
31
+ export declare function setUpgradeRunner(next: Runner | undefined): void;
32
+ export declare function describeUpgrade(projectDir?: string): Promise<UpgradeStatus>;
33
+ /**
34
+ * Runs the upgrade. `busy` is checked first: upgrading under a running
35
+ * chat turn or scheduled run would restart the server mid-reply. Resolves
36
+ * with what changed; throws UpgradeError (with everything restored) when
37
+ * it couldn't finish.
38
+ */
39
+ export declare function runUpgrade(options: {
40
+ busy: () => boolean;
41
+ projectDir?: string;
42
+ }): Promise<UpgradeOutcome>;
43
+ export {};
@@ -0,0 +1,172 @@
1
+ // Backs the console's Upgrade button (Settings section): updates a
2
+ // create-loopengine project in place to the latest loopengine and
3
+ // template, the same as running `npx create-loopengine@latest upgrade`
4
+ // by hand, then the server restarts itself on the new code.
5
+ //
6
+ // Only template-owned files (adapters/, agent-registry.ts, tsconfig.json,
7
+ // .gitignore) and package.json change. Agents, abilities, conversations
8
+ // (.sessions/), approvals (.checkpoints/), schedules (.scheduler/) and
9
+ // every .env are never touched. A template file the project edited is
10
+ // left exactly as it was (`--keep-edited`), so an unattended upgrade can
11
+ // never leave conflict markers that stop the server from starting. The
12
+ // files it may change are backed up first and restored if it fails.
13
+ import { execFile } from 'node:child_process';
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { basename, dirname, join } from 'node:path';
16
+ import semver from 'semver';
17
+ import { loopengineVersion } from '../core/version.js';
18
+ /** Everything `create-loopengine upgrade` may rewrite. */
19
+ const UPGRADED_FILES = ['adapters/http.ts', 'adapters/cli.ts', 'agent-registry.ts', 'tsconfig.json', '.gitignore', 'package.json', 'package-lock.json', '.create-loopengine.json'];
20
+ const BACKUP_DIR = '.upgrade-backups';
21
+ const KEEP_BACKUPS = 3;
22
+ export class UpgradeError extends Error {
23
+ }
24
+ function realRun(cmd, args, cwd, timeoutMs) {
25
+ return new Promise((resolve, reject) => {
26
+ execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, env: process.env }, (err, stdout, stderr) => {
27
+ if (err)
28
+ reject(Object.assign(err, { stdout: String(stdout), stderr: String(stderr) }));
29
+ else
30
+ resolve(String(stdout));
31
+ });
32
+ });
33
+ }
34
+ let run = realRun;
35
+ /** Tests replace npm/npx with a fake. */
36
+ export function setUpgradeRunner(next) {
37
+ run = next ?? realRun;
38
+ latestCache = undefined;
39
+ }
40
+ let latestCache;
41
+ async function latestLoopengine(projectDir) {
42
+ if (latestCache && latestCache.until > Date.now())
43
+ return latestCache.value;
44
+ let value = null;
45
+ try {
46
+ value = (await run('npm', ['view', 'loopengine', 'version'], projectDir, 15_000)).trim() || null;
47
+ }
48
+ catch {
49
+ value = null;
50
+ }
51
+ latestCache = { value, until: Date.now() + (value ? 10 * 60_000 : 60_000) };
52
+ return value;
53
+ }
54
+ function templateVersion(projectDir) {
55
+ const path = join(projectDir, '.create-loopengine.json');
56
+ if (!existsSync(path))
57
+ return null;
58
+ try {
59
+ return JSON.parse(readFileSync(path, 'utf8')).version ?? null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ let running = false;
66
+ export async function describeUpgrade(projectDir = process.cwd()) {
67
+ const current = loopengineVersion();
68
+ const latest = await latestLoopengine(projectDir);
69
+ const template = templateVersion(projectDir);
70
+ const status = {
71
+ current,
72
+ latest,
73
+ updateAvailable: Boolean(latest && semver.valid(current) && semver.gt(latest, current)),
74
+ templateVersion: template,
75
+ running,
76
+ };
77
+ if (!template)
78
+ status.unavailableReason = 'This project has no .create-loopengine.json, so it was not made by create-loopengine; upgrade it by hand.';
79
+ else if (!latest)
80
+ status.unavailableReason = 'Could not reach npm to check for a newer version.';
81
+ return status;
82
+ }
83
+ function backup(projectDir) {
84
+ const dir = join(projectDir, BACKUP_DIR, new Date().toISOString().replace(/[:.]/g, '-'));
85
+ for (const file of UPGRADED_FILES) {
86
+ const from = join(projectDir, file);
87
+ if (!existsSync(from))
88
+ continue;
89
+ mkdirSync(dirname(join(dir, file)), { recursive: true });
90
+ copyFileSync(from, join(dir, file));
91
+ }
92
+ // Keep the newest few.
93
+ const all = readdirSync(join(projectDir, BACKUP_DIR)).filter((name) => name !== 'pending').sort();
94
+ for (const old of all.slice(0, Math.max(0, all.length - KEEP_BACKUPS)))
95
+ rmSync(join(projectDir, BACKUP_DIR, old), { recursive: true, force: true });
96
+ return dir;
97
+ }
98
+ function restore(projectDir, dir) {
99
+ for (const file of UPGRADED_FILES) {
100
+ const saved = join(dir, file);
101
+ if (existsSync(saved))
102
+ copyFileSync(saved, join(projectDir, file));
103
+ }
104
+ }
105
+ /**
106
+ * Runs the upgrade. `busy` is checked first: upgrading under a running
107
+ * chat turn or scheduled run would restart the server mid-reply. Resolves
108
+ * with what changed; throws UpgradeError (with everything restored) when
109
+ * it couldn't finish.
110
+ */
111
+ export async function runUpgrade(options) {
112
+ const projectDir = options.projectDir ?? process.cwd();
113
+ if (running)
114
+ throw new UpgradeError('An upgrade is already running.');
115
+ if (options.busy())
116
+ throw new UpgradeError('An agent is replying or a scheduled task is running; try again when it finishes.');
117
+ if (!templateVersion(projectDir))
118
+ throw new UpgradeError('This project was not made by create-loopengine, so it can only be upgraded by hand.');
119
+ running = true;
120
+ const saved = backup(projectDir);
121
+ try {
122
+ let stdout;
123
+ try {
124
+ stdout = await run('npx', ['-y', 'create-loopengine@latest', 'upgrade', '--json', '--keep-edited'], projectDir, 10 * 60_000);
125
+ }
126
+ catch (err) {
127
+ stdout = err.stdout ?? '';
128
+ if (!stdout.trim())
129
+ throw new UpgradeError(`The upgrade could not run: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`);
130
+ }
131
+ const line = stdout.trim().split('\n').pop() ?? '';
132
+ let result;
133
+ try {
134
+ result = JSON.parse(line);
135
+ }
136
+ catch {
137
+ throw new UpgradeError('The upgrade returned something unexpected; nothing was changed.');
138
+ }
139
+ if (result.error)
140
+ throw new UpgradeError(result.error);
141
+ if (result.npmInstall && !result.npmInstall.ok)
142
+ throw new UpgradeError(`Installing the new packages failed: ${result.npmInstall.error ?? 'unknown error'}`);
143
+ // package.json may already allow the latest version while an older
144
+ // one stays installed (the lockfile keeps it); install it outright.
145
+ const latest = await latestLoopengine(projectDir);
146
+ const current = loopengineVersion();
147
+ if (latest && semver.valid(current) && semver.gt(latest, current) && !result.dependencies.some((d) => d.name === 'loopengine')) {
148
+ try {
149
+ await run('npm', ['install', '--no-audit', '--no-fund', `loopengine@^${latest}`], projectDir, 10 * 60_000);
150
+ }
151
+ catch (err) {
152
+ throw new UpgradeError(`Installing loopengine ${latest} failed: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`);
153
+ }
154
+ result.dependencies.push({ name: 'loopengine', from: current, to: `^${latest}` });
155
+ }
156
+ // Lets the workspace's start script put the backup back if the new
157
+ // code can't start (see loopengine-cloud's entrypoint); removed by it
158
+ // once the new version has stayed up.
159
+ writeFileSync(join(projectDir, BACKUP_DIR, 'pending'), basename(saved) + '\n');
160
+ latestCache = undefined;
161
+ return { fromVersion: result.fromVersion, toVersion: result.toVersion, files: result.files, dependencies: result.dependencies };
162
+ }
163
+ catch (err) {
164
+ restore(projectDir, saved);
165
+ // package.json is back; put node_modules back in line with it.
166
+ await run('npm', ['install', '--no-audit', '--no-fund'], projectDir, 10 * 60_000).catch(() => { });
167
+ throw err instanceof UpgradeError ? err : new UpgradeError(err instanceof Error ? err.message : String(err));
168
+ }
169
+ finally {
170
+ running = false;
171
+ }
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -26,6 +26,8 @@
26
26
  "dist/bin/ability-manager.js",
27
27
  "dist/bin/ability-manager.d.ts",
28
28
  "dist/core/run-agent.js",
29
+ "dist/core/version.js",
30
+ "dist/core/version.d.ts",
29
31
  "dist/core/run-agent.d.ts",
30
32
  "dist/core/known-urls.js",
31
33
  "dist/core/known-urls.d.ts",