@floless/app 0.25.2 → 0.27.0

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.
@@ -51710,8 +51710,11 @@ function parseBuildOutput(stdout) {
51710
51710
  function agentInstallArgv(id) {
51711
51711
  return ["agent", "install", assertId(id)];
51712
51712
  }
51713
+ function agentUpdateArgv(id) {
51714
+ return ["agent", "update", assertId(id)];
51715
+ }
51713
51716
  function parseAgentInstallOutput(stdout) {
51714
- const m = stdout.match(/✓\s+installed\s+(\S+)/);
51717
+ const m = stdout.match(/✓\s+(?:installed|updated)\s+(\S+)/);
51715
51718
  return { installed: m?.[1] ?? null };
51716
51719
  }
51717
51720
  function isAgentInstalled(agents, id) {
@@ -52023,6 +52026,18 @@ var aware = {
52023
52026
  if (isAgentInstalled(agents, id)) return { installed: id, output: "already installed" };
52024
52027
  return this.agentInstall(id);
52025
52028
  },
52029
+ /**
52030
+ * Update an installed agent to the latest registry version (`aware agent update
52031
+ * <id>`). Unlike `ensureAgentInstalled`, this always re-pulls — it is the Agent
52032
+ * Library's in-place "Update" action for a card whose installed version trails the
52033
+ * catalog. Like install, the pull fetches a tarball (slow), so use the generous
52034
+ * timeout. The token is NOT involved — only the agent manifest is fetched.
52035
+ */
52036
+ async agentUpdate(id) {
52037
+ const { code, stdout, stderr } = await runRawWithEnv(agentUpdateArgv(id), void 0, 6e5);
52038
+ if (code !== 0) throw new AwareError(`aware agent update ${id} failed (exit ${code})`, { stdout, stderr });
52039
+ return { ...parseAgentInstallOutput(stdout), output: stdout.trim() };
52040
+ },
52026
52041
  /**
52027
52042
  * POST /api/run — installed ID. `run` ignores --json and prints
52028
52043
  * "✓ run complete; trace at <path>"; we parse the path and read the JSONL.
@@ -52777,7 +52792,7 @@ function appVersion() {
52777
52792
  return resolveVersion({
52778
52793
  isSea: isSea2(),
52779
52794
  sqVersionXml: readSqVersionXml(),
52780
- define: true ? "0.25.2" : void 0,
52795
+ define: true ? "0.27.0" : void 0,
52781
52796
  pkgVersion: readPkgVersion()
52782
52797
  });
52783
52798
  }
@@ -52787,7 +52802,7 @@ function resolveChannel(s) {
52787
52802
  return "dev";
52788
52803
  }
52789
52804
  function appChannel() {
52790
- return resolveChannel({ isSea: isSea2(), define: true ? "0.25.2" : void 0 });
52805
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.27.0" : void 0 });
52791
52806
  }
52792
52807
 
52793
52808
  // oauth-presets.ts
@@ -54211,6 +54226,14 @@ async function runRoutine(id) {
54211
54226
  runId: res.runId ?? void 0,
54212
54227
  durationMs: Date.now() - startedAt.getTime()
54213
54228
  });
54229
+ try {
54230
+ const report = withBadge(extractReportHtml(events, r.workflow));
54231
+ if (report?.html) {
54232
+ broadcast({ type: "routine-report", id: r.id, workflow: r.workflow, nodeId: report.nodeId, html: report.html });
54233
+ }
54234
+ } catch (err) {
54235
+ console.warn(`[floless] routine "${r.id}" report surfacing failed (run still recorded ${outcome.status}):`, err instanceof Error ? err.message : err);
54236
+ }
54214
54237
  } catch (err) {
54215
54238
  const msg = err instanceof Error ? err.message : String(err);
54216
54239
  recordRun(r, {
@@ -57665,28 +57688,38 @@ async function startServer() {
57665
57688
  throw err;
57666
57689
  }
57667
57690
  });
57668
- const installingAgents = /* @__PURE__ */ new Set();
57669
- let installQueue = Promise.resolve();
57670
- app.post("/api/agents/install", async (req, reply) => {
57671
- const id = req.body?.id;
57672
- if (!id || typeof id !== "string") return reply.status(400).send({ ok: false, error: "id is required" });
57673
- if (installingAgents.has(id)) return reply.status(202).send({ ok: true, started: true });
57674
- installingAgents.add(id);
57675
- installQueue = installQueue.then(async () => {
57691
+ const agentJobsInFlight = /* @__PURE__ */ new Set();
57692
+ let agentJobQueue = Promise.resolve();
57693
+ function startAgentJob(id, action, run) {
57694
+ agentJobsInFlight.add(id);
57695
+ agentJobQueue = agentJobQueue.then(async () => {
57676
57696
  let result;
57677
57697
  try {
57678
- const res = await aware.ensureAgentInstalled(id);
57679
- result = { type: "agent-install-result", id, ok: true, installed: res.installed ?? id };
57698
+ const res = await run();
57699
+ result = { type: "agent-install-result", action, id, ok: true, installed: res.installed ?? id };
57680
57700
  } catch (err) {
57681
- result = { type: "agent-install-result", id, ok: false, error: err instanceof Error ? err.message : String(err) };
57701
+ result = { type: "agent-install-result", action, id, ok: false, error: err instanceof Error ? err.message : String(err) };
57682
57702
  } finally {
57683
- installingAgents.delete(id);
57703
+ agentJobsInFlight.delete(id);
57684
57704
  }
57685
57705
  try {
57686
57706
  broadcast(result);
57687
57707
  } catch {
57688
57708
  }
57689
57709
  });
57710
+ }
57711
+ app.post("/api/agents/install", async (req, reply) => {
57712
+ const id = req.body?.id;
57713
+ if (!id || typeof id !== "string") return reply.status(400).send({ ok: false, error: "id is required" });
57714
+ if (agentJobsInFlight.has(id)) return reply.status(202).send({ ok: true, started: true });
57715
+ startAgentJob(id, "install", () => aware.ensureAgentInstalled(id));
57716
+ return reply.status(202).send({ ok: true, started: true });
57717
+ });
57718
+ app.post("/api/agents/update", async (req, reply) => {
57719
+ const id = req.body?.id;
57720
+ if (!id || typeof id !== "string") return reply.status(400).send({ ok: false, error: "id is required" });
57721
+ if (agentJobsInFlight.has(id)) return reply.status(202).send({ ok: true, started: true });
57722
+ startAgentJob(id, "update", () => aware.agentUpdate(id));
57690
57723
  return reply.status(202).send({ ok: true, started: true });
57691
57724
  });
57692
57725
  app.get("/api/integrations", async () => {
package/dist/web/app.css CHANGED
@@ -344,6 +344,12 @@
344
344
  overflow: hidden;
345
345
  min-width: 0;
346
346
  position: relative;
347
+ /* The whole canvas is a pannable infinite surface (drag the background / middle-mouse),
348
+ so the grab affordance covers the entire grid-area — not just .topology, which is
349
+ sized around the node cards and left distant background areas as a plain arrow (#115).
350
+ Interactive controls (toolbar buttons, template chips, find input, node cards) carry
351
+ their own cursor, so grab never reads as draggable over a clickable target. */
352
+ cursor: grab;
347
353
  }
348
354
  /* While middle-mouse panning, force the grab cursor over the whole canvas. */
349
355
  .canvas.panning, .canvas.panning * { cursor: grabbing !important; }
@@ -378,9 +384,9 @@
378
384
  overflow:visible also means no scrollbar — drag the background / middle-mouse
379
385
  to pan, zoom, or Fit this infinite canvas. */
380
386
  overflow: visible;
381
- cursor: grab;
382
387
  }
383
- /* (the grabbing cursor during a pan is already forced by `.canvas.panning *` above) */
388
+ /* The grab cursor lives on the parent `.canvas` (covers the full surface, #115); the
389
+ grabbing cursor during a pan is forced by `.canvas.panning *` above. */
384
390
 
385
391
  /* Canvas chrome (top label, bottom hint + Templates bar) paints ABOVE the
386
392
  transform-panned topology with an OPAQUE background, so dragging the canvas
@@ -1159,6 +1165,17 @@
1159
1165
  text-overflow: ellipsis;
1160
1166
  white-space: nowrap;
1161
1167
  }
1168
+ /* Version as a small dim pill on the meta line — matches the canvas node-card
1169
+ `.ver` token, so the version no longer competes with the agent name (#119). */
1170
+ .lib-item .info .meta .lib-ver {
1171
+ font-family: var(--mono);
1172
+ font-size: 9px;
1173
+ color: var(--text-dim);
1174
+ background: var(--surface-2);
1175
+ padding: 1px 5px;
1176
+ border-radius: 3px;
1177
+ margin-right: 4px;
1178
+ }
1162
1179
  .lib-item .info .blurb { font-size: 11px; color: var(--text-muted); line-height: 1.4; }
1163
1180
  .lib-item .lib-fav {
1164
1181
  background: transparent;
@@ -1174,6 +1191,22 @@
1174
1191
  }
1175
1192
  .lib-item .lib-fav.faved { color: var(--star); border-color: var(--star); }
1176
1193
 
1194
+ /* Installed-tab action cluster: the transient "Update" action sits LEFT of the
1195
+ persistent ⌄ "show commands" caret (#119). */
1196
+ .lib-item .lib-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
1197
+ /* "Update" affordance — outline-accent-dim: signals "an action is available / newer
1198
+ version" without the full-accent weight reserved for primary/selected. Shown only
1199
+ when the registry is strictly ahead of the installed version; hidden when current. */
1200
+ .lib-update {
1201
+ background: transparent; border: 1px solid var(--accent-dim); color: var(--accent-dim);
1202
+ padding: 4px 9px; font-size: 11px; cursor: pointer; border-radius: 3px; flex-shrink: 0;
1203
+ line-height: 1; transition: color 0.15s, border-color 0.15s; font-family: var(--ui);
1204
+ display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
1205
+ }
1206
+ .lib-update:hover { color: var(--accent); border-color: var(--accent); }
1207
+ .lib-update[aria-busy="true"] { color: var(--text-dim); border-color: var(--border-strong); cursor: default; pointer-events: none; }
1208
+ .lib-update .rtn-spinner { width: 11px; height: 11px; border-width: 2px; }
1209
+
1177
1210
  /* Two-tab Agent Library: the tab bar sits between title and panes; each pane is a
1178
1211
  flex column so its inner .lib-list keeps scrolling within the modal's max-height.
1179
1212
  The hidden pane uses [hidden] (display:none) so it's skipped by assistive tech. */
@@ -2864,6 +2897,9 @@ body {
2864
2897
  .canvas.view-dashboard .fav-bar,
2865
2898
  .canvas.view-dashboard .notes-strip,
2866
2899
  .canvas.view-dashboard .find-overlay { display: none; }
2900
+ /* In dashboard view the pannable topology is hidden, so the canvas isn't a drag surface —
2901
+ drop the grab affordance the .canvas rule sets for the normal view (#115 follow-up). */
2902
+ .canvas.view-dashboard { cursor: default; }
2867
2903
  .dashboard { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 24px 24px; }
2868
2904
  .dashboard[hidden] { display: none; }
2869
2905
 
package/dist/web/app.js CHANGED
@@ -1422,11 +1422,15 @@ $canvasEl.addEventListener('mousedown', (e) => {
1422
1422
  startPan(e);
1423
1423
  });
1424
1424
  // Left-drag the empty canvas background also pans — so the infinite, scrollbar-less
1425
- // canvas is navigable with any pointer (trackpads have no middle button). Excludes
1426
- // node cards (those keep click-to-select + their HTML5 drag); the toolbar/hint/
1427
- // fav-bar/find aren't inside .topology, so they're naturally excluded.
1428
- $topology.addEventListener('mousedown', (e) => {
1429
- if (e.button !== 0 || e.target.closest('.agent-card')) return;
1425
+ // canvas is navigable with any pointer (trackpads have no middle button). On the full
1426
+ // `.canvas` (not just `.topology`) so panning reaches every background area, matching the
1427
+ // middle-mouse handler above (#115). Bail on interactive chrome so clicks/drags there still
1428
+ // work: node cards (their own select + HTML5 drag), the zoom toolbar, the Templates
1429
+ // fav-bar, and the find input. Non-interactive chrome (panel label, hint, notes) still pans.
1430
+ // `.dashboard` (the custom-panels view that replaces the topology) is excluded too — it has
1431
+ // its own scroll/click surface and the topology it would pan is hidden in that view.
1432
+ $canvasEl.addEventListener('mousedown', (e) => {
1433
+ if (e.button !== 0 || e.target.closest('.agent-card, .canvas-toolbar, .fav-bar, .find-overlay, .dashboard')) return;
1430
1434
  e.preventDefault();
1431
1435
  startPan(e);
1432
1436
  });
package/dist/web/aware.js CHANGED
@@ -1240,6 +1240,9 @@
1240
1240
  const ovStop = document.querySelector('.overlay-stop');
1241
1241
  if (ovStop) { ovStop.disabled = true; ovStop.textContent = 'Stopping…'; }
1242
1242
  try { await api(`/api/trigger-run/${encodeURIComponent(ft.appId)}/stop`, { method: 'POST' }); } catch { /* best effort — the session may already be gone */ }
1243
+ // Clear the canvas "running" pulse just like the regular-run stop below — the
1244
+ // early return here previously skipped it, leaving nodes stuck on `running` (#116).
1245
+ clearNodeStatus();
1243
1246
  // A deliberate stop needs no toast — the subtitle change is the confirmation.
1244
1247
  paintForegroundStopped('stopped');
1245
1248
  syncRunControls();
@@ -3293,21 +3296,31 @@
3293
3296
  if ($libModal && $libModal.classList.contains('show')) renderLibrary();
3294
3297
  }).catch(() => {});
3295
3298
  } else if (m.type === 'agent-install-result') {
3296
- // A background `aware agent install` (from the Available tab) finished. Clear
3297
- // its in-flight state, then on success refresh so it drops out of Available and
3298
- // appears under Installed; on failure restore its Install button + surface why.
3299
+ // A background `aware agent install` (Available tab) or `aware agent update`
3300
+ // (Installed tab) finished `m.action` says which. Clear its in-flight state,
3301
+ // then on success refresh the catalog (install: drops out of Available, appears
3302
+ // under Installed; update: the new version replaces the old → its Update button
3303
+ // disappears). On failure restore the card + surface why.
3304
+ const isUpdate = m.action === 'update';
3299
3305
  installingIds.delete(m.id);
3300
3306
  if (m.ok) {
3301
- const a = availableCatalog.find((x) => x.id === m.id);
3302
- showToast(`Installed “${a && a.display_name ? a.display_name : m.id} — find it in Installed`, 'ok');
3307
+ if (isUpdate) {
3308
+ showToast(`Updated “${m.id}”`, 'ok');
3309
+ } else {
3310
+ const a = availableCatalog.find((x) => x.id === m.id);
3311
+ showToast(`Installed “${a && a.display_name ? a.display_name : m.id}” — find it in Installed`, 'ok');
3312
+ }
3303
3313
  loadAgentCatalog().then(() => {
3304
3314
  if (!($libModal && $libModal.classList.contains('show'))) return;
3305
3315
  renderAvailable();
3306
3316
  if (libTab === 'installed') renderLibrary();
3307
3317
  }).catch(() => {});
3308
3318
  } else {
3309
- showToast(`Install failed: ${String(m.error || '').slice(0, 80)}`, 'err');
3310
- if ($libModal && $libModal.classList.contains('show')) renderAvailable();
3319
+ showToast(`${isUpdate ? 'Update' : 'Install'} failed: ${String(m.error || '').slice(0, 80)}`, 'err');
3320
+ if ($libModal && $libModal.classList.contains('show')) {
3321
+ if (isUpdate) renderLibrary();
3322
+ else renderAvailable();
3323
+ }
3311
3324
  }
3312
3325
  } else if (m.type === 'request-added' || m.type === 'requests-changed') {
3313
3326
  loadRequests();
@@ -3336,6 +3349,8 @@
3336
3349
  else applyTriggerSnapshot(m.id, m.snapshot);
3337
3350
  } else if (m.type === 'trigger-report') {
3338
3351
  applyTriggerReport(m);
3352
+ } else if (m.type === 'routine-report') {
3353
+ applyRoutineReport(m);
3339
3354
  } else if (m.type === 'connect-result') {
3340
3355
  // A run's credential-reconnect card may be waiting on this integration —
3341
3356
  // drive its success/fail state from the same SSE result.
@@ -3506,7 +3521,7 @@
3506
3521
  if (!tpls.length) { $favChipRow.innerHTML = ''; $favBarEmpty.style.display = 'block'; return; }
3507
3522
  $favBarEmpty.style.display = 'none';
3508
3523
  $favChipRow.innerHTML = tpls.map((t) => `
3509
- <div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="Click to use · ✎ rename · ${escapeAttr(t.category)} · ${escapeAttr((t.node.agent || t.node.kind) + (t.node.command ? '/' + t.node.command : ''))}">
3524
+ <div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="Click to use · ${escapeAttr(t.category)} · ${escapeAttr((t.node.agent || t.node.kind) + (t.node.command ? '/' + t.node.command : ''))}">
3510
3525
  <span class="cat">${escapeHtml(t.category)}</span>
3511
3526
  <span class="name">${escapeHtml(t.name)}</span>
3512
3527
  <span class="edit" data-tip="Rename / recategorize" aria-label="Edit template">✎</span>
@@ -3721,29 +3736,85 @@
3721
3736
  renderLibrary();
3722
3737
  $libModal.classList.add('show');
3723
3738
  if (!agentCatalog.length) loadAgentCatalog().then(renderLibrary).catch(reportErr);
3739
+ // Load the registry catalog too, so the Installed tab can flag outdated agents
3740
+ // with an "Update" button (and the Available tab is instant when switched to).
3741
+ ensureAvailableLoaded(() => {
3742
+ renderLibrary();
3743
+ if (libTab === 'available') renderAvailable();
3744
+ });
3724
3745
  };
3725
3746
 
3747
+ // Compare two dotted version strings numerically, segment by segment (missing
3748
+ // segments = 0). Returns 1 if a>b, -1 if a<b, 0 if equal — so the Installed tab
3749
+ // only offers an "Update" when the registry is STRICTLY ahead (never a downgrade).
3750
+ // Handles the registry's mixed schemes (e.g. "0.2.0" vs "1.0.0", "0.1.0" vs
3751
+ // "2025.0.1"). ponytail: numeric-only compare — registry versions are pure dotted
3752
+ // integers; a pre-release/build suffix (none today) compares by its leading int.
3753
+ function cmpVer(a, b) {
3754
+ const pa = String(a).split('.');
3755
+ const pb = String(b).split('.');
3756
+ const n = Math.max(pa.length, pb.length);
3757
+ for (let i = 0; i < n; i++) {
3758
+ const x = parseInt(pa[i], 10) || 0;
3759
+ const y = parseInt(pb[i], 10) || 0;
3760
+ if (x !== y) return x > y ? 1 : -1;
3761
+ }
3762
+ return 0;
3763
+ }
3764
+
3765
+ // Latest installable version for an installed id, from availableCatalog (lazy-loaded
3766
+ // on library open). Uses the catalog's `manifest_version` — the agent's manifest
3767
+ // version for the latest registry entry, i.e. the version an in-place `aware agent
3768
+ // update` actually pulls. This is the SAME axis as the installed agent's version
3769
+ // (agentCatalog/`agent list` also reports manifest.version), so the comparison is
3770
+ // valid — the catalog's `version` field is the registry INDEX key (the install
3771
+ // spec, a different axis) and must NOT be compared. null when the registry hasn't
3772
+ // loaded, the agent isn't in it (e.g. a grafted/local agent), or the CLI predates
3773
+ // manifest_version (aware < the catalog-manifest-version release) → no Update shown.
3774
+ function latestVersionFor(id) {
3775
+ const a = availableCatalog.find((x) => x.id === id);
3776
+ return a && a.manifest_version ? a.manifest_version : null;
3777
+ }
3778
+
3726
3779
  renderLibrary = function renderLibraryReal() {
3727
3780
  const q = ($libSearch.value || '').toLowerCase().trim();
3728
3781
  const items = agentCatalog.filter((a) => !q || a.id.toLowerCase().includes(q) || (a.kind || '').toLowerCase().includes(q));
3729
3782
  // Each agent is ONE grid cell: the card header + its (lazy) command list live
3730
3783
  // in the same `.lib-card`, so expanding nests the commands directly under the
3731
3784
  // picked card instead of scattering them into a neighbouring grid slot.
3732
- $libList.innerHTML = items.map((a) => `
3733
- <div class="lib-card" data-agent="${escapeAttr(a.id)}">
3785
+ $libList.innerHTML = items.map((a) => {
3786
+ const id = escapeAttr(a.id);
3787
+ // "Update" shows only when the registry is strictly ahead of the installed
3788
+ // version AND the registry catalog has loaded (else we can't know) — hidden,
3789
+ // not disabled, when up to date. A pull in flight shows a spinner instead.
3790
+ const latest = availableLoaded ? latestVersionFor(a.id) : null;
3791
+ const canUpdate = !!latest && cmpVer(latest, a.version) > 0;
3792
+ const updateBtn = installingIds.has(a.id)
3793
+ ? `<button class="lib-update" aria-busy="true" aria-label="Updating ${escapeAttr(a.id)}…" data-tip="Downloading update from the registry — this can take a minute"><span class="rtn-spinner"></span></button>`
3794
+ : canUpdate
3795
+ ? `<button class="lib-update" data-update="${id}" aria-label="Update ${escapeAttr(a.id)} to v${escapeAttr(String(latest))}" data-tip="Update to v${escapeAttr(String(latest))}">↑ v${escapeHtml(String(latest))}</button>`
3796
+ : '';
3797
+ return `
3798
+ <div class="lib-card" data-agent="${id}">
3734
3799
  <div class="lib-item">
3735
3800
  <div class="info">
3736
- <div class="name">${escapeHtml(a.id)} <span style="color:var(--text-dim);font-weight:400">v${escapeHtml(String(a.version))}</span></div>
3737
- <div class="meta">${escapeHtml(a.kind || 'agent')} · ${a.commands} command${a.commands === 1 ? '' : 's'} · ${a.skills} skill${a.skills === 1 ? '' : 's'}</div>
3801
+ <div class="name">${escapeHtml(a.id)}</div>
3802
+ <div class="meta"><span class="lib-ver">v${escapeHtml(String(a.version))}</span> ${escapeHtml(a.kind || 'agent')} · ${a.commands} command${a.commands === 1 ? '' : 's'} · ${a.skills} skill${a.skills === 1 ? '' : 's'}</div>
3803
+ </div>
3804
+ <div class="lib-actions">
3805
+ ${updateBtn}
3806
+ <button class="lib-fav" data-expand="${id}" data-tip="Show commands">⌄</button>
3738
3807
  </div>
3739
- <button class="lib-fav" data-expand="${escapeAttr(a.id)}" data-tip="Show commands">⌄</button>
3740
3808
  </div>
3741
- <div class="lib-commands" id="cmds-${escapeAttr(a.id)}" hidden></div>
3742
- </div>
3743
- `).join('') || '<div class="empty-state">No agents match.</div>';
3809
+ <div class="lib-commands" id="cmds-${id}" hidden></div>
3810
+ </div>`;
3811
+ }).join('') || '<div class="empty-state">No agents match.</div>';
3744
3812
  $libList.querySelectorAll('[data-expand]').forEach((btn) => {
3745
3813
  btn.onclick = () => expandAgent(btn.dataset.expand);
3746
3814
  });
3815
+ $libList.querySelectorAll('[data-update]').forEach((btn) => {
3816
+ btn.onclick = () => updateAgent(btn.dataset.update, btn);
3817
+ });
3747
3818
  };
3748
3819
 
3749
3820
  async function expandAgent(id) {
@@ -3943,6 +4014,49 @@
3943
4014
  });
3944
4015
  }
3945
4016
 
4017
+ // Update an installed agent in place to the latest registry version (Installed tab
4018
+ // "Update" button). Mirrors installAgent: the server runs `aware agent update`
4019
+ // asynchronously (a registry pull, ~a minute) and reports completion over SSE
4020
+ // (`agent-install-result`, action:'update'), so we flip the card to a busy spinner
4021
+ // and let the SSE handler refresh the catalog + re-render. `installingIds` is the
4022
+ // shared in-flight set (an id can't be installing and updating at once).
4023
+ function updateAgent(id, btn) {
4024
+ if (installingIds.has(id)) return; // already in flight (server dedupes too)
4025
+ installingIds.add(id);
4026
+ if (btn) { // immediate feedback before the next render
4027
+ btn.setAttribute('aria-busy', 'true');
4028
+ btn.setAttribute('aria-label', `Updating ${id}…`);
4029
+ btn.removeAttribute('data-update'); // a stray re-wire can't re-trigger it
4030
+ btn.innerHTML = '<span class="rtn-spinner"></span>';
4031
+ }
4032
+ showToast(`Updating “${id}” — this can take a minute or two`, 'info');
4033
+ api('/api/agents/update', { method: 'POST', body: JSON.stringify({ id }) })
4034
+ .catch((e) => { // the START failed (bad id / server) — completion errors arrive via SSE
4035
+ installingIds.delete(id);
4036
+ if ($libModal && $libModal.classList.contains('show')) renderLibrary();
4037
+ reportErr(e);
4038
+ });
4039
+ }
4040
+
4041
+ // Lazy-load the registry catalog once. Used by the Available tab (to list agents)
4042
+ // AND the Installed tab (to detect outdated agents → show "Update"). `onLoaded`
4043
+ // fires after the data is available (immediately if already loaded), so the caller
4044
+ // can re-render whichever view needs it. No-op on a pre-0.54 CLI (no catalog).
4045
+ function ensureAvailableLoaded(onLoaded) {
4046
+ if (availableUnsupported) return;
4047
+ if (availableLoaded) { if (onLoaded) onLoaded(); return; }
4048
+ if (availableLoading) return; // a load is already in flight; its onLoaded re-renders
4049
+ availableLoading = true;
4050
+ loadAvailableAgents()
4051
+ .then(() => { if (onLoaded) onLoaded(); })
4052
+ .catch((e) => {
4053
+ $libAvailList.innerHTML = '<div class="empty-state">Could not load available agents.</div>';
4054
+ $libAvailCount.textContent = '';
4055
+ reportErr(e);
4056
+ })
4057
+ .finally(() => { availableLoading = false; });
4058
+ }
4059
+
3946
4060
  function switchLibTab(tab) {
3947
4061
  libTab = tab;
3948
4062
  $libTabs.querySelectorAll('[data-libtab]').forEach((b) => {
@@ -3954,17 +4068,7 @@
3954
4068
  $libPaneAvailable.hidden = tab !== 'available';
3955
4069
  if (tab === 'available') {
3956
4070
  renderAvailable(); // shows the loading spinner first if not yet loaded
3957
- if (!availableLoaded && !availableLoading && !availableUnsupported) {
3958
- availableLoading = true;
3959
- loadAvailableAgents()
3960
- .then(renderAvailable)
3961
- .catch((e) => {
3962
- $libAvailList.innerHTML = '<div class="empty-state">Could not load available agents.</div>';
3963
- $libAvailCount.textContent = '';
3964
- reportErr(e);
3965
- })
3966
- .finally(() => { availableLoading = false; });
3967
- }
4071
+ ensureAvailableLoaded(() => { renderAvailable(); renderLibrary(); });
3968
4072
  setTimeout(() => { if (!$libAvailSearch.hidden) $libAvailSearch.focus(); }, 0);
3969
4073
  } else {
3970
4074
  renderLibrary(); // repaint from agentCatalog — it may have grown via an Install on the other tab
@@ -4295,6 +4399,32 @@
4295
4399
  pulseReportFrame();
4296
4400
  }
4297
4401
 
4402
+ // A SCHEDULED routine (a timed `aware app run`) produced a report (#113). Mirrors
4403
+ // applyTriggerReport for the timed path: always refresh the cache so "View report" shows
4404
+ // the latest result, and if THIS app's viewer is open, live-repaint it. An unattended
4405
+ // routine (e.g. hourly) shouldn't beep on every tick, so the toast fires ONLY when the
4406
+ // report HTML actually changed (e.g. an exception-monitor flips from "all clear" to
4407
+ // "exception detected"). Never auto-opens the viewer, never hijacks another app's open
4408
+ // viewer, never stomps an in-flight manual run or a live foreground session.
4409
+ function applyRoutineReport(m) {
4410
+ if (!m || !m.workflow || typeof m.html !== 'string') return;
4411
+ const prev = lastReportByApp.get(m.workflow);
4412
+ const changed = !prev || prev.html !== m.html;
4413
+ lastReportByApp.set(m.workflow, { nodeId: m.nodeId || null, label: 'scheduled', html: m.html });
4414
+ const open = $reportModal && $reportModal.classList.contains('show');
4415
+ if (open && currentId === m.workflow && !reportRunning && !foregroundTrigger) {
4416
+ paintReport(m.html); // unhides Share + clears the overlay
4417
+ const t = new Date().toLocaleTimeString();
4418
+ $reportSub.innerHTML =
4419
+ `<span class="live-pip" aria-hidden="true"></span>Scheduled &middot; updated ${escapeHtml(t)} — rendered from the node's output, never composed by the UI.`;
4420
+ pulseReportFrame();
4421
+ }
4422
+ if (changed) {
4423
+ const app = apps.get(m.workflow);
4424
+ showToast(`Report updated — ${app ? app.displayName : m.workflow}`, 'info');
4425
+ }
4426
+ }
4427
+
4298
4428
  // Brief edge-flash on the report stage when a live update lands, so the change
4299
4429
  // is noticeable. Forced reflow re-arms it on rapid repeats. CSS honors
4300
4430
  // prefers-reduced-motion.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.25.2",
3
+ "version": "0.27.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {