loopengine 0.1.87 → 0.1.89

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.
@@ -48,8 +48,8 @@ export interface MessagingConnector {
48
48
  /** The thread's messages, oldest first, its first message included. */
49
49
  fetchThread(conversation: string, threadTs: string): Promise<ThreadMessage[]>;
50
50
  postInThread(conversation: string, threadTs: string, text: string): Promise<void>;
51
- /** A thread reply with buttons. */
52
- postChoice(conversation: string, threadTs: string, text: string, choices: MessagingChoice[]): Promise<void>;
51
+ /** A thread reply with buttons; resolves with the new message's id. */
52
+ postChoice(conversation: string, threadTs: string, text: string, choices: MessagingChoice[]): Promise<string | undefined>;
53
53
  /** Replaces the request message itself (and its buttons) with the
54
54
  * outcome, so nobody acts on a decision already made. Optional. */
55
55
  replaceMessage?(conversation: string, ts: string, text: string): Promise<void>;
@@ -77,6 +77,7 @@ export function createSlackConnector(botToken) {
77
77
  const body = (await res.json());
78
78
  if (!body.ok)
79
79
  console.error('[loopengine] chat.postMessage (choice) failed:', body.error);
80
+ return body.ok ? body.ts : undefined;
80
81
  },
81
82
  async replaceMessage(conversation, ts, text) {
82
83
  const mrkdwn = toSlackMrkdwn(text);
@@ -15,9 +15,14 @@ export type ReplyDecision = {
15
15
  } | {
16
16
  decision: 'continue';
17
17
  };
18
- export type DecisionRequest = {
18
+ export type DecisionRequest =
19
+ /** `changeShown`: the person was just shown a proposed change to the
20
+ * arguments, waiting for their approval (our own fixed fact, not text
21
+ * anyone else wrote). */
22
+ {
19
23
  kind: 'approval';
20
24
  tool: string;
25
+ changeShown?: boolean;
21
26
  } | {
22
27
  kind: 'question';
23
28
  question: string;
@@ -18,7 +18,7 @@ function recordTool(kind) {
18
18
  }
19
19
  export function decisionPrompt(request) {
20
20
  const about = request.kind === 'approval'
21
- ? `An AI agent is waiting for approval to run the tool \`${request.tool}\`.`
21
+ ? `An AI agent is waiting for approval to run the tool \`${request.tool}\`.${request.changeShown ? ' The person was just shown a proposed change to its arguments, waiting for their approval: a plain yes or ok approves that change.' : ''}`
22
22
  : `An AI agent asked this question and is waiting for the answer: ${JSON.stringify(request.question)}${request.options?.length ? `\nOptions it offered: ${request.options.map((o) => JSON.stringify(o)).join(', ')}` : ''}`;
23
23
  const rules = request.kind === 'approval'
24
24
  ? [
@@ -60,6 +60,9 @@ export declare function handleInboundMessage(message: InboundMessage, deps: Mess
60
60
  * (full-width ones too) ignored, then compared with the fixed list. A
61
61
  * trailing question mark keeps it from matching ("yes?"). */
62
62
  export declare function isPlainApproval(text: string): boolean;
63
+ /** For a decision made outside the thread (the request's own buttons):
64
+ * replaces the thread's message with buttons, if any, with `text`. */
65
+ export declare function settleThreadChoice(deps: MessagingDeps, pendingId: string, text: string): Promise<void>;
63
66
  /** A click on one of the engine's own buttons (Approve as changed, Keep
64
67
  * original, Confirm, Cancel) — the caller has already checked the
65
68
  * person may decide. Returns what the clicked message should now say. */
@@ -54,7 +54,7 @@ export async function handleInboundMessage(message, deps) {
54
54
  }
55
55
  const modelCall = deps.createModelCall();
56
56
  const request = link.kind === 'approval'
57
- ? { kind: 'approval', tool: item.tool }
57
+ ? { kind: 'approval', tool: item.tool, changeShown: Boolean(link.proposedArgs) }
58
58
  : {
59
59
  kind: 'question',
60
60
  question: typeof item.args.question === 'string' ? item.args.question : JSON.stringify(item.args),
@@ -84,8 +84,7 @@ export async function handleInboundMessage(message, deps) {
84
84
  await say(`I can't change it that way: ${problem}. The request is unchanged.`);
85
85
  return;
86
86
  }
87
- recordThread(deps.agentDir, { ...link, proposedArgs: proposed });
88
- await connector.postChoice(message.conversation, threadTs, `Here's the change ${who} asked for:\n${formatChange(item.args, proposed, connector.escape, await deps.toolSchema(item.tool))}\nNothing else changes.`, [
87
+ await postChoice(deps, { ...link, proposedArgs: proposed }, `Here's the change ${who} asked for:\n${formatChange(item.args, proposed, connector.escape, await deps.toolSchema(item.tool))}\nNothing else changes.`, [
89
88
  { action: 'approve_edited', label: 'Approve as changed', style: 'primary', pendingId: link.pendingId },
90
89
  { action: 'keep_original', label: 'Keep original', pendingId: link.pendingId },
91
90
  { action: 'deny', label: 'Deny', style: 'danger', pendingId: link.pendingId },
@@ -100,7 +99,7 @@ export async function handleInboundMessage(message, deps) {
100
99
  await runApproved(deps, link, item.tool, args, who);
101
100
  return;
102
101
  }
103
- await connector.postChoice(message.conversation, threadTs, `Confirm to run \`${item.tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(item.tool))}`, [
102
+ await postChoice(deps, link, `Confirm to run \`${item.tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(item.tool))}`, [
104
103
  { action: 'confirm', label: 'Confirm', style: 'primary', pendingId: link.pendingId },
105
104
  { action: 'cancel_confirm', label: 'Cancel', pendingId: link.pendingId },
106
105
  ]);
@@ -112,6 +111,7 @@ export async function handleInboundMessage(message, deps) {
112
111
  if (!outcome.error && !outcome.alreadyResolved) {
113
112
  const what = link.kind === 'approval' ? `\`${item.tool}\`` : 'The question';
114
113
  await replaceRequest(connector, link, `${what} was ${verdict.decision === 'deny' ? 'denied' : `answered ("${answer}")`} by ${who} in the thread.`);
114
+ await settleChoice(deps, link, verdict.decision === 'deny' ? `Denied by ${who}.` : `Answered by ${who}.`);
115
115
  }
116
116
  return;
117
117
  }
@@ -200,10 +200,35 @@ async function runApproved(deps, link, tool, args, who) {
200
200
  await connector.postInThread(link.conversation, link.ts, `Approved${edited ? ' as changed' : ''} by ${who}. Running \`${tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(tool))}`);
201
201
  const outcome = await deps.decide(link.pendingId, { kind: 'approve', ...(edited ? { editedArgs: args } : {}) });
202
202
  await connector.postInThread(link.conversation, link.ts, outcomeText('approve', who, outcome));
203
- if (!outcome.error && !outcome.alreadyResolved)
203
+ if (!outcome.error && !outcome.alreadyResolved) {
204
204
  await replaceRequest(connector, link, `\`${tool}\` was approved${edited ? ' as changed' : ''} by ${who} in the thread.`);
205
+ await settleChoice(deps, link, `Approved${edited ? ' as changed' : ''} by ${who}.`);
206
+ }
205
207
  return outcome;
206
208
  }
209
+ /** Posts a message with buttons and remembers it, replacing the previous
210
+ * one (whose buttons no longer apply). `link` carries any other change
211
+ * to record with it, such as a proposed change. */
212
+ async function postChoice(deps, link, text, choices) {
213
+ await settleChoice(deps, link, 'Replaced by the message below.');
214
+ const choiceTs = await deps.connector.postChoice(link.conversation, link.ts, text, choices);
215
+ recordThread(deps.agentDir, { ...link, choiceTs });
216
+ }
217
+ /** Replaces the thread's message with buttons, if there is one, so its
218
+ * buttons don't linger after the request is decided. */
219
+ async function settleChoice(deps, link, text) {
220
+ if (!link.choiceTs || !deps.connector.replaceMessage)
221
+ return;
222
+ await deps.connector.replaceMessage(link.conversation, link.choiceTs, text).catch((err) => console.error('[loopengine] could not update a message with buttons:', err));
223
+ recordThread(deps.agentDir, { ...link, choiceTs: undefined });
224
+ }
225
+ /** For a decision made outside the thread (the request's own buttons):
226
+ * replaces the thread's message with buttons, if any, with `text`. */
227
+ export async function settleThreadChoice(deps, pendingId, text) {
228
+ const link = findThreadByPending(deps.agentDir, pendingId);
229
+ if (link)
230
+ await settleChoice(deps, link, text);
231
+ }
207
232
  async function replaceRequest(connector, link, text) {
208
233
  if (!connector.replaceMessage)
209
234
  return;
@@ -222,15 +247,19 @@ export async function handleMessagingAction(action, pendingId, user, deps) {
222
247
  if (!item)
223
248
  return 'This request has already been decided.';
224
249
  const who = deps.connector.mention(user);
250
+ // The clicked message is the one with buttons; the caller replaces it
251
+ // with what this returns, so it's no longer tracked.
225
252
  if (action === 'keep_original') {
226
- recordThread(deps.agentDir, { ...link, proposedArgs: undefined });
253
+ recordThread(deps.agentDir, { ...link, proposedArgs: undefined, choiceTs: undefined });
227
254
  return `${who} kept the original request. It's still waiting for a decision.`;
228
255
  }
229
- if (action === 'cancel_confirm')
256
+ if (action === 'cancel_confirm') {
257
+ recordThread(deps.agentDir, { ...link, choiceTs: undefined });
230
258
  return `${who} cancelled. Nothing ran; the request is still waiting for a decision.`;
259
+ }
231
260
  if (action === 'approve_edited' && !link.proposedArgs)
232
261
  return 'That change was dropped, so there is nothing to approve as changed.';
233
- const outcome = await runApproved(deps, link, item.tool, link.proposedArgs ?? item.args, who);
262
+ const outcome = await runApproved(deps, { ...link, choiceTs: undefined }, item.tool, link.proposedArgs ?? item.args, who);
234
263
  if (outcome.alreadyResolved)
235
264
  return 'This request has already been decided.';
236
265
  return action === 'confirm' ? `Confirmed by ${who}.` : `Approved as changed by ${who}.`;
@@ -10,6 +10,10 @@ export interface ThreadLink {
10
10
  /** A change an approver asked for, waiting to be approved as changed.
11
11
  * Recording the thread again without it drops the change. */
12
12
  proposedArgs?: Record<string, unknown>;
13
+ /** The latest message the agent posted with buttons in this thread (a
14
+ * proposed change, a Confirm), so it can be replaced once the request
15
+ * is decided and its buttons don't linger. */
16
+ choiceTs?: string;
13
17
  }
14
18
  /** Appends one request's thread. Never throws: a request that can't be
15
19
  * mapped still works through its buttons and link. */
@@ -12,8 +12,8 @@ function threadsFile(agentDir) {
12
12
  export function recordThread(agentDir, link) {
13
13
  try {
14
14
  mkdirSync(join(agentDir, '.messaging'), { recursive: true });
15
- const { app, conversation, ts, pendingId, kind, proposedArgs } = link;
16
- appendFileSync(threadsFile(agentDir), JSON.stringify({ app, conversation, ts, pendingId, kind, ...(proposedArgs ? { proposedArgs } : {}), at: new Date().toISOString() }) + '\n');
15
+ const { app, conversation, ts, pendingId, kind, proposedArgs, choiceTs } = link;
16
+ appendFileSync(threadsFile(agentDir), JSON.stringify({ app, conversation, ts, pendingId, kind, ...(proposedArgs ? { proposedArgs } : {}), ...(choiceTs ? { choiceTs } : {}), at: new Date().toISOString() }) + '\n');
17
17
  }
18
18
  catch (err) {
19
19
  console.error('[loopengine] could not record the thread:', err instanceof Error ? err.message : err);
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/not
12
12
  export { describeNotifier } from '#core/http-notifier.js';
13
13
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
14
14
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
- export { firstSighting, handleInboundMessage, handleMessagingAction, isPlainApproval, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
15
+ export { firstSighting, handleInboundMessage, handleMessagingAction, settleThreadChoice, isPlainApproval, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
16
16
  export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
17
17
  export { decideReply, proposeEdit, describeChange, type ReplyDecision, type DecisionRequest } from '#core/messaging/decide.js';
18
18
  export { recordThread, findThread, findThreadByPending, type ThreadLink } from '#core/messaging/threads.js';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
14
14
  export { describeNotifier } from '#core/http-notifier.js';
15
15
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
16
16
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
17
- export { firstSighting, handleInboundMessage, handleMessagingAction, isPlainApproval } from '#core/messaging/engine.js';
17
+ export { firstSighting, handleInboundMessage, handleMessagingAction, settleThreadChoice, isPlainApproval } from '#core/messaging/engine.js';
18
18
  export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
19
19
  export { decideReply, proposeEdit, describeChange } from '#core/messaging/decide.js';
20
20
  export { recordThread, findThread, findThreadByPending } from '#core/messaging/threads.js';
@@ -327,15 +327,23 @@ ${consoleShellScript}
327
327
  box.innerHTML = '<p class="hint">' + escapeHtml(u.unavailableReason) + '</p>' + (u.templateVersion ? upgradeCheckedLine(u) : '');
328
328
  } else if (!u.updateAvailable) {
329
329
  box.innerHTML = '<p>You' + String.fromCharCode(8217) + 're on the latest version, <code>' + escapeHtml(u.current) + '</code>.</p>' + upgradeCheckedLine(u);
330
- } else if (u.unavailableReason) {
331
- 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>';
332
- } else if (!u.canUpgrade) {
333
- 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>';
334
330
  } else {
335
- box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p>' +
336
- '<p class="hint">' + UPGRADE_NOTE + (u.restartsItself ? '' : ' This server has nothing to restart it, so restart it yourself afterwards.') + '</p>' +
337
- '<p><button type="button" class="primary" id="upgradeBtn">Upgrade to ' + escapeHtml(u.latest) + '</button></p>' +
338
- '<div id="upgradeMsg" class="hint"></div>';
331
+ // Only the template behind (a fix in adapters/http.ts and the like,
332
+ // with loopengine already current) is offered too.
333
+ var headline = u.templateUpdateAvailable
334
+ ? '<p>A template update is available: <code>' + escapeHtml(u.latestTemplate) + '</code> (this project' + String.fromCharCode(8217) + 's template is <code>' + escapeHtml(u.templateVersion) + '</code>). LoopEngine stays at <code>' + escapeHtml(u.current) + '</code>.</p>'
335
+ : '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p>';
336
+ var label = u.templateUpdateAvailable ? 'Update the template to ' + escapeHtml(u.latestTemplate) : 'Upgrade to ' + escapeHtml(u.latest);
337
+ if (u.unavailableReason) {
338
+ box.innerHTML = headline + '<p class="hint">' + escapeHtml(u.unavailableReason) + '</p>';
339
+ } else if (!u.canUpgrade) {
340
+ box.innerHTML = headline + '<p class="hint">Set LOOPENGINE_ADMIN_AUTH to upgrade from here, or run <code>npx create-loopengine@latest upgrade</code>.</p>';
341
+ } else {
342
+ box.innerHTML = headline +
343
+ '<p class="hint">' + UPGRADE_NOTE + (u.restartsItself ? '' : ' This server has nothing to restart it, so restart it yourself afterwards.') + '</p>' +
344
+ '<p><button type="button" class="primary" id="upgradeBtn">' + label + '</button></p>' +
345
+ '<div id="upgradeMsg" class="hint"></div>';
346
+ }
339
347
  }
340
348
  }
341
349
 
@@ -357,14 +365,16 @@ ${consoleShellScript}
357
365
  }
358
366
 
359
367
  // After a successful upgrade the server restarts: wait for it to answer
360
- // again on the new version, then reload so the page runs the new code.
368
+ // again on the new versions (loopengine, and the template's), then
369
+ // reload so the page runs the new code.
361
370
  function waitForRestart(target, tries) {
362
371
  if (tries > 90) { upgradeMsg('The server hasn' + String.fromCharCode(8217) + 't come back yet. Reload this page in a minute.', true); return; }
363
372
  setTimeout(function () {
364
- fetch('/config/settings', { cache: 'no-store' })
373
+ fetch('/config/upgrade', { cache: 'no-store' })
365
374
  .then(function (r) { return r.ok ? r.json() : null; })
366
- .then(function (s) {
367
- if (s && s.version === target) location.reload();
375
+ .then(function (u) {
376
+ var ready = u && !u.running && (!target.version || u.current === target.version) && (!target.template || u.templateVersion === target.template);
377
+ if (ready && tries > 0) location.reload();
368
378
  else waitForRestart(target, tries + 1);
369
379
  })
370
380
  .catch(function () { waitForRestart(target, tries + 1); });
@@ -398,14 +408,15 @@ ${consoleShellScript}
398
408
  var changed = o.files.filter(function (f) { return f.status === 'updated'; }).map(function (f) { return f.file; });
399
409
  var kept = o.files.filter(function (f) { return f.status === 'kept'; }).map(function (f) { return f.file; });
400
410
  var deps = o.dependencies.map(function (d) { return d.name + ' ' + d.from + ' ' + String.fromCharCode(8594) + ' ' + d.to; });
401
- var target = (o.dependencies.filter(function (d) { return d.name === 'loopengine'; })[0] || {}).to;
411
+ var loopengineTo = (o.dependencies.filter(function (d) { return d.name === 'loopengine'; })[0] || {}).to;
412
+ var target = { version: loopengineTo ? loopengineTo.replace(/[^0-9.]/g, '') : null, template: o.toVersion || null };
402
413
  var summary = (deps.length ? 'Installed ' + escapeHtml(deps.join(', ')) + '. ' : '') +
403
414
  (changed.length ? 'Updated ' + escapeHtml(changed.join(', ')) + '. ' : '') +
404
415
  (kept.length ? 'Left as you edited them: ' + escapeHtml(kept.join(', ')) + '. ' : '');
405
416
  btn.textContent = 'Upgraded';
406
- if (r.body.restarting && target) {
417
+ if (r.body.restarting && (target.version || target.template)) {
407
418
  upgradeMsg(summary + 'Restarting' + String.fromCharCode(8230), false);
408
- waitForRestart(target.replace(/[^0-9.]/g, ''), 0);
419
+ waitForRestart(target, 0);
409
420
  } else {
410
421
  upgradeMsg(summary + 'Restart the server to finish.', false);
411
422
  }
@@ -6,11 +6,17 @@ export interface UpgradeStatus {
6
6
  latest: string | null;
7
7
  /** When npm was last asked for `latest`. */
8
8
  checkedAt: string | null;
9
+ /** A newer loopengine, or a newer template, or both. */
9
10
  updateAvailable: boolean;
10
11
  /** The create-loopengine version this project was scaffolded or last
11
12
  * upgraded from (.create-loopengine.json); null when the project
12
13
  * wasn't made by create-loopengine, which the button can't upgrade. */
13
14
  templateVersion: string | null;
15
+ /** Latest create-loopengine on npm; null when npm couldn't be reached. */
16
+ latestTemplate: string | null;
17
+ /** Only the template is behind: a fix in adapters/http.ts and the
18
+ * other template files, with loopengine already current. */
19
+ templateUpdateAvailable: boolean;
14
20
  /** Why the button is off, when it is. */
15
21
  unavailableReason?: string;
16
22
  running: boolean;
@@ -43,18 +43,24 @@ export function setUpgradeRunner(next) {
43
43
  // A minute, so reloading Settings doesn't ask npm every time while a
44
44
  // release that just came out shows up soon (the page's Check again skips it).
45
45
  let latestCache;
46
- async function latestLoopengine(projectDir) {
47
- if (latestCache && latestCache.until > Date.now())
48
- return latestCache.value;
49
- let value = null;
46
+ async function npmLatest(name, projectDir) {
50
47
  try {
51
- value = (await run('npm', ['view', 'loopengine', 'version'], projectDir, 15_000)).trim() || null;
48
+ return (await run('npm', ['view', name, 'version'], projectDir, 15_000)).trim() || null;
52
49
  }
53
50
  catch {
54
- value = null;
51
+ return null;
55
52
  }
56
- latestCache = { value, until: Date.now() + 60_000, at: new Date().toISOString() };
57
- return value;
53
+ }
54
+ /** The latest loopengine and create-loopengine on npm, asked together. */
55
+ async function latestVersions(projectDir) {
56
+ if (latestCache && latestCache.until > Date.now())
57
+ return latestCache;
58
+ const [value, template] = await Promise.all([npmLatest('loopengine', projectDir), npmLatest('create-loopengine', projectDir)]);
59
+ latestCache = { value, template, until: Date.now() + 60_000, at: new Date().toISOString() };
60
+ return latestCache;
61
+ }
62
+ async function latestLoopengine(projectDir) {
63
+ return (await latestVersions(projectDir)).value;
58
64
  }
59
65
  function templateVersion(projectDir) {
60
66
  const path = join(projectDir, '.create-loopengine.json');
@@ -73,14 +79,18 @@ export async function describeUpgrade(projectDir = process.cwd(), options = {})
73
79
  if (options.refresh)
74
80
  latestCache = undefined;
75
81
  const current = loopengineVersion();
76
- const latest = await latestLoopengine(projectDir);
82
+ const { value: latest, template: latestTemplate } = await latestVersions(projectDir);
77
83
  const template = templateVersion(projectDir);
84
+ const loopengineBehind = Boolean(latest && semver.valid(current) && semver.gt(latest, current));
85
+ const templateBehind = Boolean(template && latestTemplate && semver.valid(template) && semver.valid(latestTemplate) && semver.gt(latestTemplate, template));
78
86
  const status = {
79
87
  current,
80
88
  latest,
81
89
  checkedAt: latestCache?.at ?? null,
82
- updateAvailable: Boolean(latest && semver.valid(current) && semver.gt(latest, current)),
90
+ updateAvailable: loopengineBehind || templateBehind,
83
91
  templateVersion: template,
92
+ latestTemplate,
93
+ templateUpdateAvailable: templateBehind && !loopengineBehind,
84
94
  running,
85
95
  };
86
96
  if (!template)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.87",
3
+ "version": "0.1.89",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",