diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js
index 73aaa4eadf..27fe60f2c2 100644
--- a/clawmetry/static/js/app.js
+++ b/clawmetry/static/js/app.js
@@ -19435,6 +19435,21 @@ function _cmSyncDismiss() {
try { localStorage.setItem('cm-sync-verified-ts', String(Date.now())); } catch (e) {}
}
+// Name what we are actually syncing. `prog.runtimes` is the node's detected
+// runtimes (see _sync_scope_runtimes in dashboard.py); the banner used to
+// hardcode "your OpenClaw workspace", which is a flat lie on a machine that
+// only runs Claude Code. Falls back to a runtime-neutral phrase when detection
+// is empty or unavailable, never to a named runtime.
+function _cmSyncScopeTitle(prog) {
+ var names = ((prog && prog.runtimes) || []).map(function (r) {
+ return (r && (r.label || r.id)) || '';
+ }).filter(Boolean);
+ if (!names.length) return t('app.syncing_your_agents', null, 'Syncing your AI agents');
+ if (names.length === 1) return 'Syncing your ' + names[0] + ' data';
+ if (names.length === 2) return 'Syncing ' + names[0] + ' and ' + names[1];
+ return 'Syncing ' + names.slice(0, 2).join(', ') + ' and ' + (names.length - 2) + ' more';
+}
+
function _cmSyncRender(prog, health) {
var bar = document.getElementById('sync-status-banner');
if (!bar) return;
@@ -19444,6 +19459,7 @@ function _cmSyncRender(prog, health) {
var errBox = document.getElementById('sync-status-error');
var title = document.getElementById('sync-status-title');
if (!sub || !details || !stepper || !errBox || !title) return;
+ title.textContent = _cmSyncScopeTitle(prog);
// Determine the active phase: highest-index phase that's running/complete.
var phase = (prog && prog.phase) || '';
diff --git a/clawmetry/templates/partials/banners.html b/clawmetry/templates/partials/banners.html
index 56e73647f6..3f587373da 100644
--- a/clawmetry/templates/partials/banners.html
+++ b/clawmetry/templates/partials/banners.html
@@ -352,7 +352,7 @@
-
Syncing your OpenClaw workspace
+
Syncing your AI agents
Discovering sessions and indexing events. Your data will appear shortly.
diff --git a/dashboard.py b/dashboard.py
index 40466a7a3c..e1c72d5faa 100644
--- a/dashboard.py
+++ b/dashboard.py
@@ -3481,6 +3481,122 @@ def detect_config(args=None):
pass
+# Cache for _sync_scope_runtimes(). The sync banner polls, and adapter
+# detect() calls glob session dirs (~3.3s measured on a busy machine), so this
+# must never run inline in a request handler. 60s is well under how fast a user
+# installs a new agent.
+_SYNC_SCOPE_CACHE = {"ts": 0.0, "runtimes": [], "running": False}
+_SYNC_SCOPE_LOCK = threading.Lock()
+
+
+def _sync_scope_runtimes():
+ """Cached runtime list for the sync banner, served off the request path.
+
+ The first call returns ``[]`` (banner shows the runtime-neutral "Syncing
+ your AI agents") and kicks a background refresh; the next poll has the real
+ list. Never blocks, never raises.
+ """
+ with _SYNC_SCOPE_LOCK:
+ stale = time.time() - float(_SYNC_SCOPE_CACHE.get("ts") or 0) >= 60
+ if stale and not _SYNC_SCOPE_CACHE["running"]:
+ _SYNC_SCOPE_CACHE["running"] = True
+ threading.Thread(target=_sync_scope_refresh_safe, daemon=True).start()
+ return _SYNC_SCOPE_CACHE["runtimes"]
+
+
+def _sync_scope_refresh_safe():
+ """Thread target: refresh the cache, and always clear the in-flight flag.
+
+ Without this a single unexpected raise would leave ``running`` True and
+ wedge the cache at its last value for the life of the process.
+ """
+ try:
+ _sync_scope_refresh()
+ except Exception as _e:
+ with _SYNC_SCOPE_LOCK:
+ _SYNC_SCOPE_CACHE["ts"] = time.time()
+ _SYNC_SCOPE_CACHE["running"] = False
+ print(f"[sync-scope] runtime detection failed: {_e}")
+
+
+def _sync_scope_refresh():
+ """Detect which agent runtimes actually have sessions on this machine.
+
+ Powers the sync banner title so it names the real runtimes ("Syncing your
+ Claude Code data") instead of asserting OpenClaw on a machine that never
+ had it. Pure filesystem detection: no DuckDB, no writer lock, never raises.
+
+ Same honesty rule as ``_detect_runtimes_for_heartbeat``: a runtime is only
+ named when it has **sessions on disk**. Presence alone is not enough, the
+ Cursor IDE creates its state dir whether or not the agent was ever used,
+ and naming a runtime we aren't actually syncing is the same lie this
+ function exists to remove.
+
+ Returns ``[{"id": ..., "label": ...}, ...]``; empty when nothing qualifies.
+ """
+ found = {} # id -> {"label": str, "sessions": int}
+
+ def _put(rid, label, sessions):
+ rid = str(rid or "").strip().lower()
+ if not rid:
+ return
+ cur = found.get(rid)
+ if cur is None or int(sessions or 0) > cur["sessions"]:
+ found[rid] = {"label": label or (cur or {}).get("label") or rid,
+ "sessions": int(sessions or 0)}
+
+ # OpenClaw / NemoClaw ship as adapters in OSS. Prefer the live registry
+ # (plugins may have overridden an adapter); fall back to the built-ins,
+ # because registration happens at app creation and can be empty here.
+ _oss = []
+ try:
+ from clawmetry.adapters import registry as _reg
+ _oss = list(_reg.detect_all())
+ except Exception:
+ pass
+ if not _oss:
+ try:
+ from clawmetry.adapters.openclaw import OpenClawAdapter as _OC
+ from clawmetry.adapters.nemo import NemoClawAdapter as _NC
+ for _cls in (_OC, _NC):
+ try:
+ _oss.append(_cls().detect())
+ except Exception:
+ pass
+ except Exception:
+ pass
+ for _r in _oss:
+ if getattr(_r, "detected", False):
+ _put(getattr(_r, "name", ""), getattr(_r, "display_name", ""),
+ getattr(_r, "session_count", 0))
+
+ # Every other runtime. The lite detector is free and always present; the
+ # family adapters are more accurate but live in clawmetry-pro, so they
+ # return nothing in OSS. Merge both, keep the higher count per runtime.
+ try:
+ from clawmetry import sync as _sync_mod
+ try:
+ for _r in (_sync_mod._detect_runtimes_lite() or []):
+ _put(_r.get("id"), _r.get("label"), _r.get("sessions"))
+ except Exception:
+ pass
+ try:
+ for _r in (_sync_mod._detect_family_runtimes() or []):
+ _put(_r.get("name"), _r.get("displayName"), _r.get("sessionCount"))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ rows = [{"id": k, "label": v["label"]}
+ for k, v in found.items() if v["sessions"] > 0]
+ with _SYNC_SCOPE_LOCK:
+ _SYNC_SCOPE_CACHE["ts"] = time.time()
+ _SYNC_SCOPE_CACHE["runtimes"] = rows
+ _SYNC_SCOPE_CACHE["running"] = False
+ return rows
+
+
def _detect_workspace_from_config():
"""Try to read workspace from Moltbot/OpenClaw agent config."""
config_paths = [
@@ -12476,15 +12592,22 @@ def _oss_integrations_shim():
# vivekchand/clawmetry#748 — Initial-sync progress for the dashboard
# banner. The sync daemon writes ~/.clawmetry/sync_progress.json after
# each phase; we just stream it through. Local-only, no auth.
+ # `runtimes` is added here (not by the daemon) so the banner can NAME what
+ # it is syncing instead of hardcoding "your OpenClaw workspace" on a
+ # machine that may only run Claude Code / Codex / Cursor.
@app.route("/api/sync-progress", endpoint="sync_progress")
def _sync_progress():
from flask import jsonify as _jsonify
progress_path = os.path.expanduser("~/.clawmetry/sync_progress.json")
if not os.path.isfile(progress_path):
- return _jsonify({"error": "no sync progress yet"}), 404
+ return _jsonify({"error": "no sync progress yet",
+ "runtimes": _sync_scope_runtimes()}), 404
try:
with open(progress_path) as _f:
- return _jsonify(json.load(_f))
+ _payload = json.load(_f)
+ if isinstance(_payload, dict):
+ _payload["runtimes"] = _sync_scope_runtimes()
+ return _jsonify(_payload)
except Exception as _e:
return _jsonify({"error": f"unreadable: {_e}"}), 500
diff --git a/tests/test_sync_banner_names_real_runtimes.py b/tests/test_sync_banner_names_real_runtimes.py
new file mode 100644
index 0000000000..8ffe2f68a1
--- /dev/null
+++ b/tests/test_sync_banner_names_real_runtimes.py
@@ -0,0 +1,180 @@
+"""Guards for the sync banner's runtime honesty (issue: hardcoded OpenClaw).
+
+The banner shipped the literal string "Syncing your OpenClaw workspace", so it
+asserted OpenClaw on every machine, including the majority that only run Claude
+Code / Codex / Cursor and have never installed OpenClaw. These tests pin the
+three halves of the fix:
+
+ 1. no hardcoded runtime name in the served template or the JS fallback,
+ 2. ``/api/sync-progress`` carries a ``runtimes`` list the banner can name,
+ 3. detection is honest (sessions on disk, never presence alone) and never
+ runs inline in the polled request handler.
+
+Each assertion fails against the pre-fix tree, which is the point.
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import time
+
+import pytest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BANNERS = os.path.join(REPO, "clawmetry", "templates", "partials", "banners.html")
+APP_JS = os.path.join(REPO, "clawmetry", "static", "js", "app.js")
+
+
+def _read(path):
+ with open(path, encoding="utf-8") as fh:
+ return fh.read()
+
+
+# ── 1. No hardcoded runtime in the banner's static copy ──────────────────────
+
+def test_banner_template_does_not_hardcode_a_runtime():
+ """The static default must be runtime-neutral: it renders before any
+ detection has landed, on machines that may have no OpenClaw at all."""
+ html = _read(BANNERS)
+ title = re.search(r'id="sync-status-title"[^>]*>([^<]*)<', html)
+ assert title, "sync-status-title element disappeared from banners.html"
+ text = title.group(1)
+ assert "OpenClaw" not in text, (
+ f"sync banner still asserts OpenClaw in its static default: {text!r}"
+ )
+
+
+def test_banner_js_fallback_is_runtime_neutral():
+ """_cmSyncScopeTitle must fall back to a neutral phrase, never to a
+ named runtime, when detection is empty or the endpoint 404s."""
+ js = _read(APP_JS)
+ assert "function _cmSyncScopeTitle(" in js, "_cmSyncScopeTitle helper is gone"
+ fn = js[js.index("function _cmSyncScopeTitle("):]
+ fn = fn[: fn.index("\n}\n") + 3]
+ assert "Syncing your AI agents" in fn, "neutral fallback copy missing"
+ for runtime in ("OpenClaw", "Claude Code", "Codex", "Cursor"):
+ assert runtime not in fn, (
+ f"{runtime!r} is hardcoded in the title builder; the name must come "
+ "from prog.runtimes, never from source"
+ )
+
+
+def test_render_sets_the_title_from_detected_runtimes():
+ """A helper nobody calls is dead code: pin the wiring too."""
+ js = _read(APP_JS)
+ body = js[js.index("function _cmSyncRender(prog, health) {"):]
+ body = body[: body.index("\nasync function _cmSyncTick")]
+ assert "_cmSyncScopeTitle(prog)" in body, (
+ "_cmSyncRender no longer sets the title from the detected runtimes"
+ )
+
+
+# ── 2. The endpoint carries the runtimes ─────────────────────────────────────
+
+class _NullArgs:
+ """Stands in for the CLI argparse Namespace.
+
+ ``detect_config()`` registers these routes, and its ``args=None`` path
+ builds a bare ``argparse.Namespace`` that then AttributeErrors on
+ ``args.log_dir``. Every attribute here answers None, so each ``if args and
+ args.x`` falls through to the normal auto-detection.
+ """
+
+ def __getattr__(self, name):
+ return None
+
+
+@pytest.fixture(scope="module")
+def client():
+ import dashboard as _d
+ # The sync-progress route is registered inside detect_config(), not at
+ # import, so a bare `import dashboard` has an empty url_map.
+ if not any("/api/sync-progress" in str(r) for r in _d.app.url_map.iter_rules()):
+ _d.detect_config(_NullArgs())
+ _d.app.config["TESTING"] = True
+ return _d.app.test_client()
+
+
+def test_sync_progress_includes_runtimes_when_file_present(client, monkeypatch, tmp_path):
+ prog = tmp_path / "sync_progress.json"
+ prog.write_text(json.dumps({"phase": "session_metadata", "status": "running"}))
+
+ real_expanduser = os.path.expanduser
+
+ def fake_expanduser(p):
+ if p == "~/.clawmetry/sync_progress.json":
+ return str(prog)
+ return real_expanduser(p)
+
+ monkeypatch.setattr(os.path, "expanduser", fake_expanduser)
+ r = client.get("/api/sync-progress")
+ assert r.status_code == 200
+ body = r.get_json()
+ assert "runtimes" in body, "banner has nothing to name without a runtimes list"
+ assert isinstance(body["runtimes"], list)
+
+
+def test_sync_progress_includes_runtimes_even_on_404(client, monkeypatch):
+ """Cold install: no progress file yet, but the banner still renders. It
+ must still be able to name the runtimes rather than falling back to a lie."""
+ real_isfile = os.path.isfile
+ monkeypatch.setattr(
+ os.path, "isfile",
+ lambda p: False if str(p).endswith("sync_progress.json") else real_isfile(p),
+ )
+ r = client.get("/api/sync-progress")
+ assert r.status_code == 404
+ assert isinstance(r.get_json().get("runtimes"), list)
+
+
+# ── 3. Detection is off the request path, and honest ─────────────────────────
+
+def test_scope_lookup_never_blocks_the_request():
+ """Detection globs session dirs and measured ~3.3s. The banner polls, so a
+ blocking lookup would stall every poll. It must serve a cache."""
+ import dashboard as _d
+ t0 = time.monotonic()
+ out = _d._sync_scope_runtimes()
+ elapsed = time.monotonic() - t0
+ assert isinstance(out, list)
+ assert elapsed < 0.5, f"_sync_scope_runtimes blocked for {elapsed:.2f}s"
+
+
+def test_zero_session_runtimes_are_never_named(monkeypatch):
+ """Presence is not usage. The Cursor IDE creates its state dir whether or
+ not the agent was ever run; naming it would be a different lie."""
+ import dashboard as _d
+ from clawmetry import sync as _sync_mod
+
+ monkeypatch.setattr(
+ _sync_mod, "_detect_runtimes_lite",
+ lambda: [{"id": "cursor", "label": "Cursor", "sessions": 0},
+ {"id": "codex", "label": "Codex", "sessions": 7}],
+ raising=False,
+ )
+ monkeypatch.setattr(_sync_mod, "_detect_family_runtimes", lambda: [], raising=False)
+ monkeypatch.setattr(
+ "clawmetry.adapters.registry.detect_all", lambda: [], raising=False
+ )
+ monkeypatch.setattr(
+ _d, "_SYNC_SCOPE_CACHE",
+ {"ts": 0.0, "runtimes": [], "running": False}, raising=False,
+ )
+ ids = {r["id"] for r in _d._sync_scope_refresh()}
+ assert "codex" in ids, "a runtime with real sessions must be named"
+ assert "cursor" not in ids, "a 0-session runtime must never be named"
+
+
+def test_detection_failure_clears_the_inflight_flag(monkeypatch):
+ """A raise inside the refresh thread must not wedge the cache forever."""
+ import dashboard as _d
+ monkeypatch.setattr(
+ _d, "_SYNC_SCOPE_CACHE",
+ {"ts": 0.0, "runtimes": [], "running": True}, raising=False,
+ )
+ def boom():
+ raise RuntimeError("detector exploded")
+ monkeypatch.setattr(_d, "_sync_scope_refresh", boom, raising=False)
+ _d._sync_scope_refresh_safe()
+ assert _d._SYNC_SCOPE_CACHE["running"] is False