From c43da8950e2c455a913acea9569201d75edd7215 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 13:59:11 -0700 Subject: [PATCH 001/174] [eric] swarm: scan workspace file bytes for secrets on export, not just payload keys --- backend/apps/swarm/redact.py | 18 ++++++++++++++++++ backend/apps/swarm/ziputil.py | 8 +++++++- backend/tests/test_swarm_bundle.py | 13 +++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backend/apps/swarm/redact.py b/backend/apps/swarm/redact.py index 1a97d20c..cc39b063 100644 --- a/backend/apps/swarm/redact.py +++ b/backend/apps/swarm/redact.py @@ -79,3 +79,21 @@ def find_denied_keys(value: Any, _path: str = "") -> list[str]: for i, v in enumerate(value): found.extend(find_denied_keys(v, f"{_path}[{i}]")) return found + + +def _looks_secret(text: str) -> bool: + return any(pat.search(text) for pat in _CONTENT_PATTERNS) + + +def find_secrets_in_files(files: dict[str, bytes]) -> list[str]: + """Paths of any file whose text body holds a secret-shaped literal. Payloads + get scrubbed key-and-content, but raw workspace files (an app's source) were + only key-scanned, so a key hardcoded in a .js would slip. Binary files are + skipped (a null byte means it isn't text someone pasted a token into).""" + hits: list[str] = [] + for path, data in files.items(): + if b"\x00" in data[:4096]: + continue + if _looks_secret(data.decode("utf-8", errors="ignore")): + hits.append(path) + return hits diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index b4682695..bdf1aa5c 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -12,7 +12,7 @@ import shutil import tempfile import zipfile -from .redact import find_denied_keys +from .redact import find_denied_keys, find_secrets_in_files MANIFEST_NAME = "manifest.json" @@ -46,6 +46,12 @@ def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> raise BundleError( f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" ) + leaky_files = find_secrets_in_files(files) + if leaky_files: + raise BundleError( + f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " + "remove it (use an environment variable) and try again" + ) entries: dict[str, bytes] = {} for bid, payload in payloads.items(): entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8") diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index dcb5b733..66fc20a4 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -99,6 +99,19 @@ def test_pack_refuses_denied_key(): pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}) +def test_pack_refuses_secret_in_workspace_file(): + # A key hardcoded in app source (not .env) must not ride along; pack scans + # file bytes, not just payload keys. + leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n" + with pytest.raises(BundleError): + pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}) + + +def test_pack_allows_clean_workspace_file(): + raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/app.js": b"export default 1"}) + assert zipfile.is_zipfile(io.BytesIO(raw)) + + def test_app_export_drops_machine_env(tmp_path, monkeypatch): # The live .env holds the source machine's absolute paths + pinned port; it # must never ride along. .env.example (portable) does. From ad1a9b439e015db9162f9b1d93788aa124492258 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:00:19 -0700 Subject: [PATCH 002/174] [eric] swarm: validate manifest structure at stage time (root/dup-id/edge integrity, outside checksum) --- backend/apps/swarm/closure.py | 21 ++++++++++++++++++ backend/tests/test_swarm_bundle.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index c7e7054d..ef15cfac 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -170,6 +170,26 @@ def swarm_filename(name: str) -> str: # ---------- import: staging ---------- +def validate_manifest(manifest: Manifest) -> None: + """Structural integrity of the untrusted part of a .swarm. The checksum + covers entity payloads + files but NOT the manifest itself, so an attacker + can rewrite root/edges/paths freely; catch the breakages that would import + silently wrong (a root pointing nowhere, a duplicate id that drops an + entity, an edge or path that doesn't resolve inside the bundle).""" + seen: set[str] = set() + for e in manifest.entities: + if e.bundle_id in seen: + raise BundleError("bundle manifest has duplicate entity ids") + seen.add(e.bundle_id) + if not e.path.startswith("entities/") or ".." in e.path.split("/"): + raise BundleError("bundle manifest has an out-of-tree entity path") + if manifest.root.bundle_id not in seen: + raise BundleError("bundle manifest root is not one of its entities") + for edge in manifest.edges: + if edge.from_ not in seen or edge.to not in seen: + raise BundleError("bundle manifest has an edge to an unknown entity") + + def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]: warnings: list[str] = [] if is_zip(raw): @@ -179,6 +199,7 @@ def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]: raw_manifest = read_manifest(sandbox) verify_checksum(sandbox, raw_manifest) manifest = Manifest(**raw_manifest) + validate_manifest(manifest) except BundleError: shutil.rmtree(sandbox, ignore_errors=True) raise diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 66fc20a4..1b997fd5 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -280,6 +280,40 @@ def test_commit_rolls_back_created_on_failure(skill_store, tmp_path): assert not (skill_store / "rollme.md").exists() +def test_manifest_duplicate_ids_rejected(): + # Two entities sharing a bundle_id silently collapse in the topo/summary + # dicts, dropping one; reject up front. (The manifest is outside the checksum.) + from backend.apps.swarm.closure import validate_manifest + from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest + ref = EntityRef(type=EntityType.skill, bundle_id="dup", name="A", path="entities/dup") + m = Manifest(bundle_id="b", root=ref, entities=[ref, ref], + preview=BundlePreview(root_type=EntityType.skill, root_name="A")) + with pytest.raises(BundleError): + validate_manifest(m) + + +def test_manifest_root_not_in_entities_rejected(): + from backend.apps.swarm.closure import validate_manifest + from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest + root = EntityRef(type=EntityType.skill, bundle_id="root", name="A", path="entities/root") + other = EntityRef(type=EntityType.skill, bundle_id="other", name="B", path="entities/other") + m = Manifest(bundle_id="b", root=root, entities=[other], + preview=BundlePreview(root_type=EntityType.skill, root_name="A")) + with pytest.raises(BundleError): + validate_manifest(m) + + +def test_manifest_edge_to_unknown_entity_rejected(): + from backend.apps.swarm.closure import validate_manifest + from backend.apps.swarm.models import BundlePreview, DependencyEdge, EntityRef, Manifest + ref = EntityRef(type=EntityType.dashboard, bundle_id="d", name="D", path="entities/d") + m = Manifest(bundle_id="b", root=ref, entities=[ref], + edges=[DependencyEdge(**{"from": "d", "to": "ghost"})], + preview=BundlePreview(root_type=EntityType.dashboard, root_name="D")) + with pytest.raises(BundleError): + validate_manifest(m) + + def _zip_with(name, data=b"x"): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: From 2bb5ee05f6dc8f2c8228fd87a0d9f621b124b61f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:01:50 -0700 Subject: [PATCH 003/174] [eric] swarm: generative dashboard remap-invariant test + symlink unpack rejection --- backend/tests/test_swarm_bundle.py | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 1b997fd5..fea9408c 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -230,6 +230,73 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch): assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped +def test_dashboard_remap_invariant_generative(monkeypatch): + # The hand-written remap tests only check the id-bearing fields I remembered. + # Generate random dashboards and assert the real invariant on a serialize -> + # import round-trip: no source-local id and no bundle id survives into the + # imported layout, and every card id is a freshly-minted local id. This is + # what catches "someone adds a new layout field holding a session id and + # forgets to remap it." + import random + + from backend.apps.swarm.entities import dashboards as dmod + from backend.apps.swarm.exportable import RemapTable + from backend.apps.swarm.models import EntityType + + written: dict = {} + monkeypatch.setattr(dmod, "_write", lambda did, doc: written.update({did: doc})) + monkeypatch.setattr(dmod, "_retag_sessions", lambda ids, did: None) + + rng = random.Random(1234) + for _ in range(60): + sess = [f"S{i}" for i in range(rng.randint(0, 5))] + apps = [f"A{i}" for i in range(rng.randint(0, 4))] + s_bid = {s: f"sbid{i}" for i, s in enumerate(sess)} + a_bid = {a: f"abid{i}" for i, a in enumerate(apps)} + + class Ctx: + def bundle_id_for(self, t, lid): + if t == EntityType.session: + return s_bid.get(lid) + if t == EntityType.app: + return a_bid.get(lid) + return None + + layout = { + "cards": {s: {"session_id": s, "x": rng.randint(0, 9)} for s in sess}, + "view_cards": {a: {"output_id": a} for a in apps}, + "browser_cards": { + f"b{i}": {"browser_id": f"b{i}", "url": "u", + "spawned_by": (rng.choice(sess) if sess and rng.random() < 0.7 else None)} + for i in range(rng.randint(0, 3)) + }, + "expanded_session_ids": (sess + ["ORPHAN"]) if rng.random() < 0.5 else list(sess), + } + payload = dmod.DashboardExportable("d-src", "D", {"name": "D", "layout": layout}).serialize(Ctx()) + + remap = RemapTable() + fresh_sess = {s: f"new-{s_bid[s]}" for s in sess} + fresh_apps = {a: f"new-{a_bid[a]}" for a in apps} + for s in sess: + remap.assign(s_bid[s], fresh_sess[s]) + for a in apps: + remap.assign(a_bid[a], fresh_apps[a]) + + did = dmod.DashboardExportable.import_(payload, {}, remap) + L = written[did]["layout"] + + forbidden = set(sess) | set(apps) | set(s_bid.values()) | set(a_bid.values()) + assert set(L["cards"]) == set(fresh_sess.values()) + assert set(L["view_cards"]) == set(fresh_apps.values()) + for cid, card in L["cards"].items(): + assert cid not in forbidden and card["session_id"] == cid + for oid, card in L["view_cards"].items(): + assert oid not in forbidden and card["output_id"] == oid + assert set(L["expanded_session_ids"]) <= set(fresh_sess.values()) + for card in L["browser_cards"].values(): + assert card["spawned_by"] is None or card["spawned_by"] in set(fresh_sess.values()) + + def test_checksum_rejects_tampering(skill_store): _make_skill(skill_store, "tmp", "Tmp", "# original") raw, _ = closure.build_bundle(EntityType.skill, "tmp") @@ -331,6 +398,18 @@ def test_absolute_path_rejected(): unpack(_zip_with("/etc/evil")) +def test_symlink_entry_rejected(): + # A symlink entry could point outside the sandbox once followed; unpack must + # refuse it before writing anything. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zi = zipfile.ZipInfo("link") + zi.external_attr = 0o120777 << 16 + zf.writestr(zi, "/etc/passwd") + with pytest.raises(BundleError): + unpack(buf.getvalue()) + + def test_too_many_entries_rejected(): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: From ad00fd19aedc68770db152c3d96b004c14fba6cb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:05:21 -0700 Subject: [PATCH 004/174] [eric] error-classify: schema-translation 400s aren't auth; gemini RESOURCE_EXHAUSTED is transient --- backend/apps/agents/core/error_classify.py | 35 ++++++++++++++++++++++ backend/tests/test_v2_invariants.py | 27 +++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index d3ad8074..3a5d4aea 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -12,10 +12,30 @@ _TRANSIENT_CAPACITY_PATTERNS = re.compile( r"|internal\s+server\s+error" r"|rate[_\s-]?limit(?:_error)?" r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed" + r"|resource[_\s-]?exhausted" r"|upstream\s+connect\s+error)", re.IGNORECASE, ) +# A first message ships the full tool schema; 9Router rewrites Anthropic +# tools[].input_schema into Gemini function_declarations / OpenAI params, and a +# construct it can't translate makes the provider 400 (INVALID_ARGUMENT) with +# zero tokens. That is NOT auth, reconnecting won't help, the request shape is +# wrong, so we classify it apart and stop the catch-all from showing a +# "reconnect your subscription" card for a tool-schema 400. +_TRANSLATION_ERROR_PATTERNS = re.compile( + r"(?:function_declarations" + r"|invalid_argument" + r"|invalid\s+json\s+payload" + r"|unknown\s+name\b" + r"|cannot\s+find\s+field" + r"|proto\s+field" + r"|input_schema" + r"|\btools\[\d+\]" + r")", + re.IGNORECASE, +) + # Patterns that look rate-limit-ish but are actually non-transient (user quota, # auth, context-window tier gate). Must NOT retry, upgrading, reauthing, or # trimming context is required. The long-context-required variant is what @@ -69,6 +89,17 @@ def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool: )) +def _is_translation_error(exc: BaseException, extra_text: str = "") -> bool: + """True when the upstream 400 is a tool-schema / protocol translation + failure (9Router rewriting Anthropic tools into Gemini function_declarations + or OpenAI params), not auth or capacity. Kept distinct so the catch-all + stops mislabeling a schema 400 as an expired-subscription reconnect card.""" + combined = f"{exc!s}\n{extra_text}".strip() + if not combined: + return False + return bool(_TRANSLATION_ERROR_PATTERNS.search(combined)) + + def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool: """True when the upstream error is a 401/403 auth failure. @@ -80,6 +111,10 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool: combined = f"{exc!s}\n{extra_text}".strip() if not combined: return False + # A tool-schema translation 400 can carry provider/connection wording that + # trips the auth regex below; it isn't auth, so don't claim it is. + if _is_translation_error(exc, extra_text): + return False return bool(re.search( r"\b(401|403)\b" r"|invalid\s+authentication\s+credentials" diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 6b7f0bf3..51d146ed 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -523,6 +523,33 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key(): assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview" +def test_error_classify_schema_translation_400_is_not_auth(): + """A 9Router tool-schema translation 400 can carry provider/connection + wording that trips the auth regex, so it used to surface a misleading + 'reconnect your subscription' card for what is really a schema bug. The + translation guard must win: schema 400 -> not auth; a real auth failure + with no translation signature still reads as auth.""" + from backend.apps.agents.core.error_classify import _is_auth_error, _is_translation_error + both = Exception("provider not connected: 400 INVALID_ARGUMENT at " + "tools[0].function_declarations[0].parameters") + assert _is_translation_error(both) + assert not _is_auth_error(both), "schema-400 must not be classified as auth" + # Pure auth failures (no translation signature) still classify as auth. + assert _is_auth_error(Exception("provider not connected: gemini")) + assert _is_auth_error(Exception("401 invalid authentication credentials")) + assert not _is_translation_error(Exception("401 invalid authentication credentials")) + + +def test_error_classify_gemini_resource_exhausted_is_transient(): + """gemini-cli's free-tier 429 surfaces as RESOURCE_EXHAUSTED; it must count + as transient so the existing backoff/retry catches it instead of dying as a + hard first-message error. A 403 (hard auth/quota) must still NOT retry.""" + from backend.apps.agents.core.error_classify import _is_transient_capacity_error + assert _is_transient_capacity_error(Exception("429 RESOURCE_EXHAUSTED: Quota exceeded")) + assert _is_transient_capacity_error(Exception("RESOURCE_EXHAUSTED")) + assert not _is_transient_capacity_error(Exception("403 permission denied")) + + def test_banned_models_not_offered(): """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a From bd73d498285c727bc2dd5238b06c48625104e896 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:22:48 -0700 Subject: [PATCH 005/174] [eric] mcp-gate: property-test the activation gate (forwarded => activated) + Z3 formal proof --- backend/tests/formal/README.md | 20 +++++++ backend/tests/formal/mcp_gate_proof.py | 83 ++++++++++++++++++++++++++ backend/tests/test_v2_invariants.py | 44 ++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 backend/tests/formal/README.md create mode 100644 backend/tests/formal/mcp_gate_proof.py diff --git a/backend/tests/formal/README.md b/backend/tests/formal/README.md new file mode 100644 index 00000000..5bb7cf13 --- /dev/null +++ b/backend/tests/formal/README.md @@ -0,0 +1,20 @@ +# Formal proofs + +Machine-checked proofs of safety/security invariants that the unit/property +tests can only *sample*. A property test tries thousands of cases; an SMT proof +is exhaustive over the modeled domain (assert the negation, `unsat` => theorem). + +Not wired into prod or CI, and excluded from the packaged build (under `tests/`). +Run manually: + +``` +pip install z3-solver +python backend/tests/formal/mcp_gate_proof.py +``` + +- **`mcp_gate_proof.py`** , the MCP dispatch-gate invariant (`agent_manager._build_mcp_servers`): + a gated session forwards a server *only if* it was activated, an empty + activation list forwards zero, and a denied server is never forwarded. Sampled + by `tests/test_v2_invariants.py::test_mcp_gate_only_forwards_activated_servers`; + proven for all inputs here. The script also refutes a deliberately-buggy gate + (activation check dropped) so the proof can't be vacuous. diff --git a/backend/tests/formal/mcp_gate_proof.py b/backend/tests/formal/mcp_gate_proof.py new file mode 100644 index 00000000..6bf03dc6 --- /dev/null +++ b/backend/tests/formal/mcp_gate_proof.py @@ -0,0 +1,83 @@ +"""Formal proof (Z3 / SMT) of the MCP dispatch-gate security invariant. + +The product rule "MCP tools are reachable only after MCPActivate" is enforced at +dispatch in agent_manager._build_mcp_servers: for a gated session a server is +forwarded to the model only if its sanitized name is in session.active_mcps. + +tests/test_v2_invariants.py::test_mcp_gate_only_forwards_activated_servers +SAMPLES that contract (400 random cases). This SMT proof is exhaustive over the +modeled domain: we assert the negation of each property and ask Z3 for a +counterexample. `unsat` means none can exist, so the property is a theorem, +true for every possible input, not just the ones a test happened to try. + +Not wired into prod or CI. Run manually: + pip install z3-solver && python backend/tests/formal/mcp_gate_proof.py +""" + +from z3 import And, Bool, Implies, Not, Or, Solver, sat, unsat + + +def forwarded(installed, allowed, denied, active_is_none, active_t): + """Faithful model of the gate decision for one arbitrary server `t` + (agent_manager.py:165-203). A server ships to the model iff it is an + installed+configured MCP tool, passes the permission gate, isn't fully + denied, and EITHER the session is legacy (active_mcps is None) OR the + server is in active_mcps. Proving it for an arbitrary symbolic `t` proves + it for all servers.""" + return And(installed, allowed, Not(denied), Or(active_is_none, active_t)) + + +def buggy_forwarded(installed, allowed, denied, active_is_none, active_t): + """The same gate with the activation check dropped, used to show the proof + has teeth: Z3 must be able to refute the no-leak property for this variant.""" + return And(installed, allowed, Not(denied)) + + +def prove(name: str, claim) -> bool: + """`claim` should be valid (true for every input). Proven by showing its + negation is unsatisfiable.""" + s = Solver() + s.add(Not(claim)) + if s.check() == unsat: + print(f" PROVED: {name}") + return True + print(f" FAILED: {name} counterexample: {s.model()}") + return False + + +def main() -> None: + installed = Bool("installed") + allowed = Bool("allowed") + denied = Bool("denied") + active_is_none = Bool("active_is_none") # legacy session (no activation gate) + active_t = Bool("active_t") # server t is in active_mcps + fwd = forwarded(installed, allowed, denied, active_is_none, active_t) + gated = Not(active_is_none) + + print("Proving MCP dispatch-gate invariants (exhaustive over all inputs):") + ok = True + # A. No leak: a gated session never forwards a non-activated server. + ok &= prove("gated => (forwarded(t) -> activated(t))", + Implies(And(gated, fwd), active_t)) + # B. Empty activation => zero servers (no t is active, so none ship). + ok &= prove("gated & !activated(t) => !forwarded(t)", + Implies(And(gated, Not(active_t)), Not(fwd))) + # C. The permission gate still binds: a denied server is never forwarded. + ok &= prove("denied(t) => !forwarded(t)", Implies(denied, Not(fwd))) + + # Teeth: the buggy gate (activation check dropped) MUST be refutable, else + # the proof above would be vacuous. + print("Sanity-checking the proof has teeth (a buggy gate must be refuted):") + bug = buggy_forwarded(installed, allowed, denied, active_is_none, active_t) + s = Solver() + s.add(Not(Implies(And(gated, bug), active_t))) + assert s.check() == sat, "buggy gate should leak but Z3 couldn't refute it" + print(f" REFUTED (as expected): a gate without the activation check leaks; " + f"counterexample = {s.model()}") + + print("\nALL GATE PROPERTIES PROVED" if ok else "\nPROOF FAILED") + raise SystemExit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 51d146ed..6ea61908 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -550,6 +550,50 @@ def test_error_classify_gemini_resource_exhausted_is_transient(): assert not _is_transient_capacity_error(Exception("403 permission denied")) +@pytest.mark.asyncio +async def test_mcp_gate_only_forwards_activated_servers(): + """Dispatch-layer security invariant (the non-bypassable enforcement of + 'MCP tools only via MCPActivate'): for a GATED session (active_mcps is a + list), _build_mcp_servers forwards ONLY servers whose sanitized name is in + active_mcps; an empty list forwards ZERO; None is the legacy all-allowed + path. The model cannot reach an unactivated server no matter what it asks + for. Property-checked over random installed sets and random activation + subsets, plus the two boundary cases.""" + import random + from types import SimpleNamespace + from backend.apps.agents.agent_manager import AgentManager + mgr = AgentManager() + names = ["gmail", "drive", "slack", "reddit", "notion", "airtable"] + + def installed(): + return [SimpleNamespace(name=n, mcp_config={"x": 1}, enabled=True, + auth_status="configured", auth_type="apikey") for n in names] + + # allowed_tools == get_all_tool_names() bypasses the (separate) permission + # gate so we isolate the ACTIVATION gate. _sanitize_server_name -> identity. + with patch("backend.apps.agents.agent_manager.load_all_tools", side_effect=installed), \ + patch("backend.apps.agents.agent_manager.get_all_tool_names", return_value=["__ALL__"]), \ + patch("backend.apps.agents.agent_manager._sanitize_server_name", side_effect=lambda n: n), \ + patch("backend.apps.agents.agent_manager._is_fully_denied", return_value=False), \ + patch("backend.apps.agents.agent_manager.derive_mcp_config", side_effect=lambda t: {"command": "x"}): + allowed = ["__ALL__"] + # Boundary 1: empty activation list -> zero servers, always. + assert await mgr._build_mcp_servers(allowed, active_mcps=[]) == {} + # Boundary 2: None (legacy) -> permission gate only, all forwarded. + assert set((await mgr._build_mcp_servers(allowed, active_mcps=None)).keys()) == set(names) + # Property: forwarded set is ALWAYS a subset of the activated set, and + # equals exactly the activated-and-installed intersection. + rng = random.Random(1234) + for _ in range(400): + active = rng.sample(names, rng.randint(0, len(names))) + # throw in a bogus name the gate must never invent a server for + if rng.random() < 0.3: + active = active + ["ghost-not-installed"] + forwarded = set((await mgr._build_mcp_servers(allowed, active_mcps=active)).keys()) + assert forwarded <= set(active), f"leaked {forwarded - set(active)} for active={active}" + assert forwarded == (set(active) & set(names)), f"mismatch for active={active}" + + def test_banned_models_not_offered(): """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a From 46e92c9fe08b4f7976f59b2264c62b2e39e0197c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:34:16 -0700 Subject: [PATCH 006/174] [eric] dashboards: drop orphan session cards from GET so a dead-session card stops 404ing on load --- backend/apps/dashboards/dashboards.py | 37 ++++++++++++++++++++++++++- backend/tests/test_v2_invariants.py | 27 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 81dbf0c1..f5fc346d 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -363,10 +363,45 @@ async def generate_name(dashboard_id: str): return {"name": dashboard.name, "auto_named": True} +def _strip_orphan_session_cards(data: dict) -> None: + """Drop layout cards (and expanded ids) whose agent session no longer exists + anywhere, in memory OR on disk. The frontend mounts an AgentChat per card and + GETs its session; a card pointing at a vanished session (e.g. an empty + never-saved session) 404s on every load and flashes a dead "connect a model" + card before the client reconciles it away. The `gone()` test is the exact + condition that makes GET /sessions/{id} 404, so it removes precisely those + cards and nothing else. Filtering the RESPONSE (never the stored file) is + non-destructive: a wrong check can only hide a card for one response, not + delete it. Drafts have no backend session yet, so they're always kept.""" + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.manager.session.session_store import _load_session_data + layout = data.get("layout") + if not isinstance(layout, dict): + return + cards = layout.get("cards") + if not isinstance(cards, dict): + return + + def gone(sid: str) -> bool: + if sid.startswith("draft-") or sid in agent_manager.sessions: + return False + return _load_session_data(sid) is None + + orphans = [sid for sid in cards if gone(sid)] + for sid in orphans: + cards.pop(sid, None) + if orphans: + exp = layout.get("expanded_session_ids") + if isinstance(exp, list): + layout["expanded_session_ids"] = [s for s in exp if s not in orphans] + + @dashboards.router.get("/{dashboard_id}") async def get_dashboard(dashboard_id: str): dashboard = _load(dashboard_id) - return dashboard.model_dump(mode="json") + data = dashboard.model_dump(mode="json") + _strip_orphan_session_cards(data) + return data @dashboards.router.put("/{dashboard_id}") diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 6ea61908..d9ba5094 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -594,6 +594,33 @@ async def test_mcp_gate_only_forwards_activated_servers(): assert forwarded == (set(active) & set(names)), f"mismatch for active={active}" +def test_dashboard_get_strips_only_orphan_session_cards(): + """A layout card whose session vanished (gone from memory AND disk) makes the + frontend GET /sessions/{id} 404 on every load and flash a dead card. The + dashboard GET filters those orphan cards out of the response, but must keep + live (in-memory) cards, on-disk cards, and drafts. Non-destructive: only the + response is filtered, never the stored layout.""" + from types import SimpleNamespace + from backend.apps.dashboards import dashboards as D + data = {"layout": { + "cards": { + "live": {"session_id": "live"}, # in memory + "ondisk": {"session_id": "ondisk"}, # closed but on disk + "draft-1": {"session_id": "draft-1"}, # unsent draft, no backend session yet + "ghost": {"session_id": "ghost"}, # gone from memory AND disk -> would 404 + }, + "expanded_session_ids": ["live", "ghost"], + }} + fake_mgr = SimpleNamespace(sessions={"live": object()}) + on_disk = {"ondisk": {"id": "ondisk"}} + with patch("backend.apps.agents.agent_manager.agent_manager", fake_mgr), \ + patch("backend.apps.agents.manager.session.session_store._load_session_data", + side_effect=lambda sid: on_disk.get(sid)): + D._strip_orphan_session_cards(data) + assert set(data["layout"]["cards"].keys()) == {"live", "ondisk", "draft-1"}, "only the ghost should be dropped" + assert data["layout"]["expanded_session_ids"] == ["live"], "ghost dropped from expanded too" + + def test_banned_models_not_offered(): """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a From b0401b06d0a8600de8e101c611d53d5abc42f84a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:46:48 -0700 Subject: [PATCH 007/174] [eric] openai: route the thinking-slider effort param to gpt/codex (not just claude) --- backend/apps/agents/agent_manager.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index cef90c76..a48e8a2a 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1824,6 +1824,15 @@ class AgentManager: options_kwargs["thinking"] = {"type": "disabled"} elif level in ("low", "medium", "high"): options_kwargs["effort"] = level + elif api_type in ("openai", "codex"): + # GPT-5 family + Codex take reasoning_effort; 9Router carries + # the Anthropic-shaped `effort` across to it, so the slider + # works for OpenAI too, not just Claude. Every OpenAI/Codex + # model we expose is reasoning-capable (registry has no + # non-reasoning ones), so no per-model gate. No "disabled" + # form on these, so "off" just omits the param. + if level in ("low", "medium", "high"): + options_kwargs["effort"] = level except Exception as e: logger.debug(f"thinking_level param injection skipped: {e}") From 96f0ce78a75b8ce4e5fb80567320d1e5386db936 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 15:22:56 -0700 Subject: [PATCH 008/174] [eric] boot: fetch subscription status on launch (connected subs were stale until Settings) --- frontend/src/app/Main.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 28e564f4..2046427f 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -9,6 +9,7 @@ import Alert from '@mui/material/Alert'; import { store } from '../shared/state/store'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchSettings, updateSettings, markFreeTrialArmSettled } from '@/shared/state/settingsSlice'; +import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice'; import { fetchModels } from '@/shared/state/modelsSlice'; import { API_BASE } from '@/shared/config'; import { @@ -222,6 +223,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); + // Connected subscriptions live in their own slice; without this the dashboard + // (and the onboarding gate) think no model is connected until the user opens + // Settings > Models, so a fresh launch shows a false "connect a model" empty + // state and the welcome cursor never fires. Refetched after sync + on focus below. + dispatch(fetchSubscriptionStatus()); fetch(`${API_BASE}/subscription/sync`, { method: 'POST' }) .then((r) => { if (r.ok) dispatch(fetchSettings()); @@ -236,12 +242,12 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = // The backend arms server-side regardless of whether the browser can read the mint // response (a transient boot-time CORS/timing miss makes `data` unreadable), so refetch // unconditionally, the GET is the only reliable signal the UI gets that it armed. - .finally(() => { dispatch(fetchSettings()); dispatch(markFreeTrialArmSettled()); }); + .finally(() => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); dispatch(markFreeTrialArmSettled()); }); }); }, [dispatch]); useEffect(() => { - const onFocus = () => { dispatch(fetchSettings()); }; + const onFocus = () => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); }; window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); }, [dispatch]); From c9efbeca39889eeee910c05323e233aa5435a642 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 16:42:04 -0700 Subject: [PATCH 009/174] [eric] onboarding: restart-tour reload + cursor yields silently on off-script click --- .../app/components/Onboarding/OnboardingDirector.ts | 13 +++++++++++++ .../src/app/components/Onboarding/ac/acRuntime.ts | 13 +++++++++++++ .../Settings/sections/general/GeneralAdvanced.tsx | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/frontend/src/app/components/Onboarding/OnboardingDirector.ts b/frontend/src/app/components/Onboarding/OnboardingDirector.ts index 6522adf3..2dbaa7e7 100644 --- a/frontend/src/app/components/Onboarding/OnboardingDirector.ts +++ b/frontend/src/app/components/Onboarding/OnboardingDirector.ts @@ -98,8 +98,20 @@ class OnboardingDirector { controller.abort(); } }; + // Yield to the user: the runtime fires this when, during a wait for a + // SPECIFIC click target, the user instead clicks somewhere off-script. Back + // off silently (reason 'user-cancel' suppresses acRuntime's recovery popup) + // rather than nagging or auto-performing the action. It is scoped to + // click-target waits in the runtime, so it can't cancel free-interaction + // waits (e.g. connecting a model in Settings, where the user must click + // non-tour controls). + const onUserOffscript = () => { + report('step_aborted_user_offscript', { step_id: stepId }); + controller.abort('user-cancel'); + }; window.addEventListener('openswarm:onboarding:lost_target', onLost); window.addEventListener('hashchange', onRouteChange); + window.addEventListener('openswarm:onboarding:user_offscript', onUserOffscript); try { await runStep({ @@ -115,6 +127,7 @@ class OnboardingDirector { } finally { window.removeEventListener('openswarm:onboarding:lost_target', onLost); window.removeEventListener('hashchange', onRouteChange); + window.removeEventListener('openswarm:onboarding:user_offscript', onUserOffscript); if (this.currentAbort === controller) { this.currentAbort = null; } diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index 92e9c955..eea5e174 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -860,12 +860,25 @@ function waitForCondition( ) ) { finish(false); + return; } + // Off-script click during a wait for a specific target: if it's not any + // tour control and not the cursor/popup, the user has gone their own + // way, so tell the director to back off (it aborts the step silently). + // Scoped here to click-target waits so free-interaction waits + // (redux_predicate / event_bus) never cancel on a stray click. + if (!(el instanceof Element)) return; + if (el.closest('[data-onboarding], [data-select-type]')) return; + for (let n: Element | null = el; n; n = n.parentElement) { + if (parseInt(window.getComputedStyle(n).zIndex || '0', 10) >= 10500) return; + } + window.dispatchEvent(new CustomEvent('openswarm:onboarding:user_offscript', { detail: { target: cond.target } })); }; document.addEventListener('click', handler, true); cleanup = () => document.removeEventListener('click', handler, true); return; } + case 'redux_predicate': { const check = () => { const value = cond.selector(store.getState()); diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx index 62803460..01ce5b94 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx @@ -117,6 +117,10 @@ const GeneralAdvanced: React.FC<{ dispatch(resetTour()); dispatch(closeSettingsModal()); onboardingBus.emit('settings:closed'); + // In-place reset can't re-arm the welcome cursor's once-per-mount + // guard, so the tour never re-fired without a reload; reload from the + // now-cleared storage is the reliable restart (matches the workaround). + window.location.reload(); }} sx={{ color: c.text.secondary, From 7a91abba8d260efa88dd7a7254a91233d09c4be5 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 16:43:55 -0700 Subject: [PATCH 010/174] [eric] onboarding: drop linear step-gating so every roadmap step is freely explorable --- .../components/Onboarding/steps/stepUnlock.ts | 72 ++++--------------- 1 file changed, 12 insertions(+), 60 deletions(-) diff --git a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts index eef57122..209b87be 100644 --- a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts +++ b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts @@ -1,73 +1,25 @@ -// Soft, earned unlocks for the onboarding panel. A locked step is still fully -// usable in the app, this only gates the guided spotlight + shows a lock icon -// with a one-line teaser, so the tour reveals things ONE AT A TIME instead of -// dumping the whole feature surface at once. -// -// Tiers: -// - get_started (launch an agent, connect a model): unlocked from the start. -// - Tier 1 "the basics": the FIRST feature unlocks on your first agent win. -// - Tier 2 "going further": a CHAIN, each feature unlocks once you finish the -// previous tour step, so the panel only ever surfaces the NEXT thing. -// -// Off-script still counts: doing a thing yourself (opening a browser, installing -// a skill) unlocks its step immediately, so exploring is never punished. +// Onboarding is a playground, not homework: every roadmap step is freely +// explorable in any order. A linear FEATURE_CHAIN used to gate each step on +// finishing the one above it (the ๐Ÿ”’ "Finish the step above" teasers); that read +// as a chore, so the gating is gone and nothing is locked. The exported shapes +// are kept so the panel/roadmap callers don't change. import { useMemo } from 'react'; import type { RootState } from '@/shared/state/store'; import { useAppSelector } from '@/shared/hooks'; -import { - hasAnyAgentLaunched, - hasAnyBrowserSpawned, - hasAnySkillInstalled, -} from './skipPredicates'; import { STEPS } from './index'; -// Order features reveal in. Index 0 is tier 1 (first thing after the win); the -// rest are the tier-2 chain, each gated on finishing the one before it. -const FEATURE_CHAIN = [ - 'enable_actions', - 'use_browser', - 'agent_use_browser', - 'agent_control_agents', - 'install_skill', - 'make_app', -]; - -// A feature can ALSO unlock when its real-world milestone is met off-script. -const OFF_SCRIPT: Record boolean> = { - use_browser: hasAnyBrowserSpawned, - agent_use_browser: hasAnyBrowserSpawned, - install_skill: hasAnySkillInstalled, -}; - -const HINTS: Record = { - enable_actions: 'Run your first agent', - use_browser: 'Finish the step above', - agent_use_browser: 'Finish the step above', - agent_control_agents: 'Finish the step above', - install_skill: 'Finish the step above', - make_app: 'Finish the step above', -}; - -export function isStepUnlocked(stepId: string, s: RootState): boolean { - const idx = FEATURE_CHAIN.indexOf(stepId); - if (idx === -1) return true; // get_started entry points are always open - if (idx === 0) return hasAnyAgentLaunched(s); // tier 1 opens on the first win - const prevDone = (s.onboardingProgress?.completedSteps ?? []).includes( - FEATURE_CHAIN[idx - 1], - ); - return prevDone || (OFF_SCRIPT[stepId]?.(s) ?? false); +export function isStepUnlocked(_stepId: string, _s: RootState): boolean { + return true; } -export function unlockHintFor(stepId: string): string | null { - return HINTS[stepId] ?? null; +export function unlockHintFor(_stepId: string): string | null { + return null; } -/** Set of currently-unlocked step ids. Keyed on a stable string so the selector - * only re-renders when the unlock set actually changes. */ +/** Set of currently-unlocked step ids: every step, always. Selector form kept + * so callers' memoization is unchanged. */ export function useUnlockedStepIds(): Set { - const key = useAppSelector((s) => - STEPS.filter((st) => isStepUnlocked(st.id, s)).map((st) => st.id).join('|'), - ); + const key = useAppSelector(() => STEPS.map((st) => st.id).join('|')); return useMemo(() => new Set(key ? key.split('|') : []), [key]); } From 39c837fee77dd41683147b0ffeac01f489c941f2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 18:58:44 -0700 Subject: [PATCH 011/174] [eric] swarm: carry the chat transcript when sharing an agent (was dropped, scrub layer guards secrets) --- backend/apps/swarm/entities/sessions.py | 40 ++++++++++++----- backend/tests/test_swarm_bundle.py | 60 +++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py index 847601e6..805f06fc 100644 --- a/backend/apps/swarm/entities/sessions.py +++ b/backend/apps/swarm/entities/sessions.py @@ -1,10 +1,12 @@ -"""SessionExportable: an agent card on a shared dashboard. We carry only the -recipe (name, model, mode, system prompt, allowed tools) and deliberately DROP -the chat transcript (privacy + size), runtime state, costs, the worktree path, -and active_mcps (importing must never silently grant tool access, per the gate). -Its MCP/actions, provider, and built-in mode become import requirements so the -importer is walked through enabling them. The dashboard re-points dashboard_id -after import.""" +"""SessionExportable: an agent card on a shared dashboard. We carry the recipe +(name, model, mode, system prompt, allowed tools) AND the chat transcript so a +shared agent arrives with the conversation that produced it, that's the whole +point of sharing one. The transcript rides through the same scrub layer as every +payload, so any secret-shaped string in it is redacted before it leaves. We still +DROP runtime state, costs, the worktree path, and active_mcps: importing must +never silently grant tool access, per the gate. Its MCP/actions, provider, and +built-in mode become import requirements so the importer is walked through +enabling them. The dashboard re-points dashboard_id after import.""" from __future__ import annotations from datetime import datetime, timezone @@ -14,7 +16,14 @@ from ..exportable import DepRef, ExportContext, RemapTable from ..models import EntityType, Requirement, RequirementKind _BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} -_KEEP = ("name", "provider", "model", "mode", "system_prompt", "allowed_tools", "max_turns", "thinking_level") +# Transcript fields ride along so the shared agent keeps its history; ids inside +# (message ids, branch ids, their parent/fork refs) are self-consistent within +# the one session file, so they carry verbatim with no remap. +_KEEP = ( + "name", "provider", "model", "mode", "system_prompt", "allowed_tools", + "max_turns", "thinking_level", + "messages", "branches", "active_branch_id", "tool_group_meta", +) class SessionExportable: @@ -70,6 +79,14 @@ class SessionExportable: from backend.apps.agents.manager.session.session_store import _save_session sid = uuid4().hex now = datetime.now(timezone.utc).isoformat() + # Older bundles (made before transcripts were carried) have no messages; + # fall back to a single empty main branch so the imported agent is valid. + branches = payload.get("branches") or { + "main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now} + } + active_branch_id = payload.get("active_branch_id") or "main" + if active_branch_id not in branches: + active_branch_id = next(iter(branches), "main") doc = { "id": sid, "name": payload.get("name") or "Agent", @@ -81,9 +98,10 @@ class SessionExportable: "allowed_tools": payload.get("allowed_tools") or [], "max_turns": payload.get("max_turns"), "thinking_level": payload.get("thinking_level") or "auto", - "messages": [], - "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now}}, - "active_branch_id": "main", + "messages": payload.get("messages") or [], + "branches": branches, + "active_branch_id": active_branch_id, + "tool_group_meta": payload.get("tool_group_meta") or {}, "active_mcps": [], "dashboard_id": None, # the dashboard import re-points this "browser_id": None, diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index fea9408c..9d08a595 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -168,23 +168,75 @@ def test_workflow_unavailable_on_this_branch(): WorkflowExportable.import_({"title": "x"}, {}, RemapTable()) -def test_session_export_strips_transcript_and_secrets(): +def test_session_export_carries_transcript_drops_runtime_and_secrets(): from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.redact import scrub_payload data = { "name": "A", "provider": "anthropic", "model": "sonnet", "mode": "agent", "system_prompt": "hi", "allowed_tools": ["Read"], - "messages": [{"role": "user", "content": "private chat"}], + "messages": [ + {"id": "m1", "role": "user", "content": "private chat", "branch_id": "main"}, + {"id": "m2", "role": "assistant", "content": "token is sk-ant-abcdefghij0123456789"}, + ], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}}, + "active_branch_id": "main", + "tool_group_meta": {"g1": {"label": "x"}}, "active_mcps": ["Gmail"], "cwd": "/Users/me/repo", "cost_usd": 9.9, "sdk_session_id": "x", } ex = SessionExportable("s1", "A", data) out = ex.serialize(None) - for gone in ("messages", "cwd", "active_mcps", "cost_usd", "sdk_session_id"): + # The transcript now rides along, that's the point of sharing an agent. + assert out["messages"][0]["content"] == "private chat" + assert out["active_branch_id"] == "main" and "main" in out["branches"] + assert out["tool_group_meta"] == {"g1": {"label": "x"}} + # Runtime, identity, and gate state still never leave. + for gone in ("cwd", "active_mcps", "cost_usd", "sdk_session_id"): assert gone not in out - assert out["model"] == "sonnet" and out["mode"] == "agent" + # The closure runs scrub_payload on every payload, so a secret-shaped + # string sitting in the transcript is redacted before it ships. + assert "sk-ant-" not in json.dumps(scrub_payload(out)) reqs = ex.requirements() assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs) +def test_session_import_restores_transcript_without_granting_mcp(monkeypatch): + from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.exportable import RemapTable + from backend.apps.agents.manager.session import session_store + saved: dict = {} + monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc})) + payload = { + "name": "A", "model": "sonnet", "mode": "agent", + "messages": [{"id": "m1", "role": "user", "content": "hi", "branch_id": "main"}], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}}, + "active_branch_id": "main", + "tool_group_meta": {"g1": {"label": "x"}}, + } + sid = SessionExportable.import_(payload, {}, RemapTable()) + doc = saved[sid] + assert doc["messages"][0]["content"] == "hi" + assert doc["active_branch_id"] == "main" + assert doc["tool_group_meta"] == {"g1": {"label": "x"}} + # The gate stays shut: a shared agent never arrives with MCP access. + assert doc["active_mcps"] == [] + # The dashboard import re-points this; it must never be the sharer's id. + assert doc["dashboard_id"] is None + + +def test_session_import_old_bundle_without_transcript(monkeypatch): + # A bundle made before transcripts were carried has no messages; it must + # still import as a valid empty-history agent (single main branch), not crash. + from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.exportable import RemapTable + from backend.apps.agents.manager.session import session_store + saved: dict = {} + monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc})) + sid = SessionExportable.import_({"name": "Old", "model": "sonnet"}, {}, RemapTable()) + doc = saved[sid] + assert doc["messages"] == [] + assert doc["active_branch_id"] == "main" and "main" in doc["branches"] + + def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): from backend.apps.swarm.entities.dashboards import DashboardExportable from backend.apps.swarm.models import EntityType From 3bf1b0da79d08d4bf211b98d9e6e2661a748e9a7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 18:58:50 -0700 Subject: [PATCH 012/174] [eric] mcp: break the ToolSearch loop, redirect a stuck agent to MCPActivate for gated servers --- backend/apps/agents/agent_manager.py | 54 +++++++++++++++ .../agents/manager/prompt/prompt_context.py | 35 ++++++++++ backend/tests/test_v2_invariants.py | 69 +++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a48e8a2a..a12a1e56 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -66,6 +66,8 @@ from backend.apps.agents.manager.prompt.prompt_context import ( _resolve_attached_skills, _resolve_forced_tools, _resolve_mode, + TOOLSEARCH_LOOP_THRESHOLD, + toolsearch_loop_redirect, ) from backend.apps.agents.manager.prompt.attachments import ( _build_dir_tree, @@ -209,6 +211,29 @@ class AgentManager: logger.info(f"[MCP-DEBUG] Final mcp_servers: {list(mcp_servers.keys())}") return mcp_servers + def _gated_mcp_server_names(self, allowed_tools: list[str], active_mcps: list[str] | None) -> list[str]: + """Names of installed MCP servers withheld from the SDK because they're + not activated yet, exactly the servers the model sees in the + block but can't reach via ToolSearch. The only way in is + MCPActivate; used to steer a model looping on ToolSearch to the gate.""" + active_set = set(active_mcps or []) + names: list[str] = [] + try: + for tool in load_all_tools(): + if not (tool.mcp_config and tool.enabled and tool.auth_status in ("configured", "connected")): + continue + tool_ref = f"mcp:{tool.name}" + if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names(): + continue + if _is_fully_denied(tool): + continue + server_name = _sanitize_server_name(tool.name) + if server_name not in active_set: + names.append(server_name) + except Exception: + logger.exception("gated MCP server enumeration failed") + return names + def _build_connected_tools_context(self, allowed_tools: list[str]) -> str | None: return _build_connected_tools_context(allowed_tools, get_all_tool_names) @@ -801,11 +826,40 @@ class AgentManager: ) tool_start_times: dict[str, float] = {} + # Counts ToolSearch calls in a row (no other tool between them). A run + # of these with empty results is the "looping on ToolSearch" wedge. + _ts_loop = {"n": 0} async def pre_tool_hook(input_data, tool_use_id, context): tool_name = input_data.get("tool_name", "") hook_event = input_data.get("hook_event_name", "PreToolUse") + # ToolSearch loop-breaker. Gated MCP servers are withheld from the + # SDK until MCPActivate, so the CLI's native ToolSearch can never + # find them; small models thrash (empty ToolSearch, retry) for + # minutes until the user pauses. Let the first couple through, then + # redirect to the gate. Any non-ToolSearch call is real progress, so + # the counter resets. Gated-server lookup is deferred behind the + # threshold so the common (non-looping) path stays free. + if tool_name == "ToolSearch": + _ts_loop["n"] += 1 + if _ts_loop["n"] >= TOOLSEARCH_LOOP_THRESHOLD: + _reason = toolsearch_loop_redirect( + _ts_loop["n"], + self._gated_mcp_server_names(session.allowed_tools, session.active_mcps), + ) + if _reason: + logger.info(f"[MCP-DEBUG] ToolSearch loop-breaker fired for {session_id} (n={_ts_loop['n']})") + return { + "hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "deny", + "permissionDecisionReason": _reason, + } + } + else: + _ts_loop["n"] = 0 + if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) policy, sensitive_pattern = _maybe_override_policy( diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index b6935496..4c6236cb 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -98,6 +98,35 @@ def _build_connected_tools_context(allowed_tools: list[str], get_all_tool_names: ) +# A run of this many ToolSearch calls with no other tool between them is the +# "looping on ToolSearch" wedge: the model hunts for a gated MCP server's tools, +# which ToolSearch can never see, gets empty results, and retries. Two free +# calls (a power user with many activated MCPs may legitimately ToolSearch to +# load a deferred tool); redirect on the third. +TOOLSEARCH_LOOP_THRESHOLD = 3 + + +def toolsearch_loop_redirect(consecutive_toolsearch: int, gated_servers: list[str]) -> str | None: + """The feedback to hand a model that's stuck calling ToolSearch in a row. + None until it crosses the threshold; then a steer toward MCPActivate (the + only path to a gated server) plus a reminder its other tools are already + loaded. Pure so the loop-break boundary is unit-testable.""" + if consecutive_toolsearch < TOOLSEARCH_LOOP_THRESHOLD: + return None + reason = ( + "ToolSearch can't load anything here, every tool you can use is already " + "active and callable by name, so there's nothing to search for. " + ) + if gated_servers: + reason += ( + "If you need an app you don't see yet (email, calendar, drive, etc.), " + "it's gated: call MCPActivate(server_name) with one of these and its " + f"tools become callable next turn: {', '.join(gated_servers)}. " + ) + reason += "Stop calling ToolSearch." + return reason + + def _build_browser_context(dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None: """Build a context block listing browser cards and delegation instructions. @@ -304,6 +333,12 @@ def _build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str] "Calendar/Drive, the equivalent OpenSwarm server is listed below; " "activate that one via MCPActivate instead." ) + sections.append( + "1b. The native `ToolSearch` tool CANNOT see these servers, they're " + "hidden from it until activated, so searching for them returns nothing " + "and just burns turns. Never ToolSearch for an app/integration; go " + "straight to MCPActivate." + ) sections.append( "2. After MCPActivate returns, end the turn, a follow-up turn fires " "automatically with the new tools available." diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index d9ba5094..1a240ae0 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -210,6 +210,75 @@ async def test_gate_stress_random_activations(): ) +# =========================================================================== +# Group A2, ToolSearch loop-breaker +# =========================================================================== +# Gated MCP servers are withheld from the SDK, so the CLI's native ToolSearch +# can never see them; small models loop (empty ToolSearch -> retry) until the +# user pauses. The break must (a) not fire on the first call or two (a power +# user may legitimately ToolSearch a deferred tool), (b) fire once it's clearly +# stuck, steering to MCPActivate, and (c) reset when any real tool runs. + + +def test_toolsearch_redirect_holds_below_threshold(): + from backend.apps.agents.manager.prompt.prompt_context import ( + toolsearch_loop_redirect, + TOOLSEARCH_LOOP_THRESHOLD, + ) + for n in range(1, TOOLSEARCH_LOOP_THRESHOLD): + assert toolsearch_loop_redirect(n, ["gmail"]) is None, f"must not redirect at n={n}" + + +def test_toolsearch_redirect_fires_at_threshold_and_names_gated_servers(): + from backend.apps.agents.manager.prompt.prompt_context import ( + toolsearch_loop_redirect, + TOOLSEARCH_LOOP_THRESHOLD, + ) + reason = toolsearch_loop_redirect(TOOLSEARCH_LOOP_THRESHOLD, ["google-workspace", "slack"]) + assert reason is not None + assert "MCPActivate" in reason + assert "google-workspace" in reason and "slack" in reason + assert "Stop calling ToolSearch" in reason + + +def test_toolsearch_redirect_works_with_no_gated_servers(): + # Even with nothing to activate, the steer must still tell the model its + # tools are already loaded so it stops searching (no crash on empty list). + from backend.apps.agents.manager.prompt.prompt_context import ( + toolsearch_loop_redirect, + TOOLSEARCH_LOOP_THRESHOLD, + ) + reason = toolsearch_loop_redirect(TOOLSEARCH_LOOP_THRESHOLD, []) + assert reason is not None + assert "MCPActivate" not in reason # nothing to point at + assert "Stop calling ToolSearch" in reason + + +@pytest.mark.asyncio +async def test_gated_server_names_surface_only_inactive_servers(): + """The steer list must mirror the gate: connected-but-not-active servers + only, never one that's already activated (callable) or denied.""" + from backend.apps.agents.agent_manager import AgentManager + fake_tools = [_fake_tool("Gmail"), _fake_tool("Slack"), _fake_tool("Notion")] + with patch("backend.apps.agents.agent_manager.load_all_tools", return_value=fake_tools): + mgr = AgentManager() + names = mgr._gated_mcp_server_names( + allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], + active_mcps=["gmail"], # already activated -> not "gated" + ) + assert "gmail" not in names, "activated server must not appear as gated" + assert "slack" in names and "notion" in names + + +@pytest.mark.asyncio +async def test_gated_server_names_empty_when_all_active(): + from backend.apps.agents.agent_manager import AgentManager + fake_tools = [_fake_tool("Gmail")] + with patch("backend.apps.agents.agent_manager.load_all_tools", return_value=fake_tools): + mgr = AgentManager() + assert mgr._gated_mcp_server_names(["mcp:Gmail"], ["gmail"]) == [] + + # =========================================================================== # Group B, needs_fresh_session soft-restart # =========================================================================== From de2e70ca8fed339ff1abd5d7eb9ad89c5018f6ab Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 15 Jun 2026 19:00:40 -0700 Subject: [PATCH 013/174] [aidan] fix/browser-early-close: let agent keep browser open when result lives on the page (#88) --- backend/apps/agents/browser/browser_agent.py | 24 +++++++++++++++++++ backend/apps/agents/browser/browser_schema.py | 19 ++++++++++++--- backend/apps/dashboards/models.py | 4 ++++ .../src/shared/state/dashboardLayoutSlice.ts | 10 ++++++++ frontend/src/shared/ws/WebSocketManager.ts | 12 +++++++--- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 14ab253d..12419b27 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -941,6 +941,7 @@ async def run_browser_agent( done_called = False done_message = "" done_success = True + done_keep_open = False # Completion detection: once an irreversible SEND has confirmed, the goal is # met. The model otherwise stalls re-verifying what the confirm already proved # (measured: send done at turn ~11, then ~12 wasted perception turns). We drive @@ -1427,6 +1428,7 @@ async def run_browser_agent( done_called = True done_message = (tu.input.get("message") or "").strip() done_success = tu.input.get("success", True) is not False + done_keep_open = tu.input.get("keep_open", False) is True tool_results.append({ "type": "tool_result", "tool_use_id": tu.id, "content": [{"type": "text", "text": "ok"}], @@ -2122,6 +2124,28 @@ async def run_browser_agent( }) except Exception as e: logger.debug(f"[browser-playbook] distill skipped: {e}") + # The model asked to leave the browser open because the deliverable lives + # on the page (a video playing, a page to read). Pin the card so the + # auto-close on parent finish skips it. Only on honest success: never pin + # a broken or ghost run open. The keep broadcast lands before the parent + # reaches terminal state (it awaits this run), so the frontend has the + # flag set before any close path runs. + if honest and done_keep_open and dashboard_id: + try: + from backend.apps.dashboards.dashboards import _load, _save + dashboard = _load(dashboard_id) + card = dashboard.layout.browser_cards.get(browser_id) + if card is not None: + card.keep_open = True + dashboard.updated_at = datetime.now() + _save(dashboard) + await ws_manager.broadcast_global("dashboard:browser_card_keep", { + "dashboard_id": dashboard_id, + "browser_id": browser_id, + }) + except Exception as e: + logger.warning(f"[browser-agent {session_id}] keep_open persist failed: {e}") + agent_manager._sync_session_close(session) await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index afc14fac..b4e59f2d 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -117,6 +117,17 @@ BROWSER_TOOLS_SCHEMA = [ "(login wall, missing info, something blocked you). Default true." ), }, + "keep_open": { + "type": "boolean", + "description": ( + "Set true ONLY when the result IS the open page and the user will keep " + "using it right now: a video or audio playing, a page you opened for them " + "to read or watch, a download you started, or a place left ready for them " + "to take over. The browser then stays put instead of closing. Leave false " + "(default) for info tasks where you just look something up and report the " + "answer back, since there's nothing left to keep on screen." + ), + }, }, "required": ["message"], }, @@ -885,9 +896,11 @@ SYSTEM_PROMPT = ( "tool, never by typing a sentence. Put your reply to the user in Done's `message`, " "written like a normal chat reply: what got done plus the human proof (the name, the " "time, what's now on screen), in one or two plain sentences with zero interface words. " - "Set `success` false if you couldn't finish. For irreversible actions, only report " - "success with real proof you actually observed (the name and where/when you saw it), " - "just phrased for a person, not for a machine." + "Set `success` false if you couldn't finish. Set `keep_open` true when the result is the " + "open page itself and the user keeps using it now (a video playing, a page opened to " + "read, a download started), so the browser stays instead of closing. For irreversible " + "actions, only report success with real proof you actually observed (the name and " + "where/when you saw it), just phrased for a person, not for a machine." ) MAX_TURNS = 40 diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 52be0543..717fcf19 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -40,6 +40,10 @@ class BrowserCardPosition(BaseModel): # Used by the frontend to auto-remove the browser when its owner agent # reaches a terminal completed/error state. spawned_by: Optional[str] = None + # When the agent leaves the deliverable on the page (a video playing, a page + # to read), it sets this so the frontend's auto-close on parent finish skips + # the card and the browser stays put. + keep_open: bool = False class NotePosition(BaseModel): diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index f3534ebc..3e36cd1b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -59,6 +59,7 @@ export interface BrowserCardPosition { zOrder: number; /** Agent session that spawned this browser; auto-removed when its owner reaches terminal state. */ spawned_by?: string | null; + keep_open?: boolean; /** Dashboard this card belongs to; cards render and persist only on their owning dashboard. */ dashboard_id?: string; } @@ -663,6 +664,14 @@ const dashboardLayoutSlice = createSlice({ delete state.endingBrowserCards[action.payload]; }, + keepBrowserCardOpen(state, action: PayloadAction) { + const card = state.browserCards[action.payload]; + if (!card) return; + card.keep_open = true; + // Undo any in-flight ending mark in case a close path raced ahead. + delete state.endingBrowserCards[action.payload]; + }, + suspendBrowserCard(state, action: PayloadAction<{ browserId: string; dataUrl: string }>) { if (!state.browserCards[action.payload.browserId]) return; state.suspendedBrowserCards[action.payload.browserId] = { @@ -1098,6 +1107,7 @@ export const { resumeBrowserCard, markBrowserCardEnding, cancelBrowserCardEnding, + keepBrowserCardOpen, pasteBrowserCard, updateBrowserCardUrl, addBrowserTab, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 092c4a6b..258f62e4 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -23,7 +23,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { getAuthToken } from '../config'; @@ -510,7 +510,7 @@ class WebSocketManager { ) { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { - if (card.spawned_by === session_id) { + if (card.spawned_by === session_id && !card.keep_open) { store.dispatch(markBrowserCardEnding({ browserId: card.browser_id, status: data.status, })); @@ -733,7 +733,7 @@ class WebSocketManager { if (closedStatus === 'completed' || closedStatus === 'error') { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { - if (card.spawned_by === session_id) { + if (card.spawned_by === session_id && !card.keep_open) { store.dispatch(markBrowserCardEnding({ browserId: card.browser_id, status: closedStatus, })); @@ -743,6 +743,12 @@ class WebSocketManager { } break; + case 'dashboard:browser_card_keep': + if (data.browser_id) { + store.dispatch(keepBrowserCardOpen(data.browser_id)); + } + break; + case 'dashboard:browser_card_added': if (data.browser_card) { // Tag with origin dashboard so the card renders only on the dashboard From 65eaab47aed19de7dc78ae84c18efd9050a747da Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 01:59:20 -0700 Subject: [PATCH 014/174] [eric] tests: make aux-model resolver tests deterministic (set Pro bearer token, stop reading live provider state) --- backend/tests/test_v2_invariants.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 1a240ae0..6d42299f 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -467,6 +467,10 @@ async def test_resolve_aux_model_anthropic_pro_returns_proxy(): settings = AppSettings() settings.connection_mode = "openswarm-pro" settings.openswarm_proxy_url = "https://api.openswarm.test" + # A real Pro-connected user carries a bearer token; proxy_auth reads it. + # Without it the resolver can't see Pro and falls through to the raise, + # which is what made this test depend on live machine state. + settings.openswarm_bearer_token = "test-pro-token" with patch("backend.apps.nine_router.is_running", return_value=False): model_id, base = await registry.resolve_aux_model(settings) assert "haiku" in model_id @@ -1205,6 +1209,7 @@ async def test_aux_failover_anthropic_to_codex(): settings = AppSettings() settings.connection_mode = "openswarm-pro" # provides anthropic fallback settings.openswarm_proxy_url = "https://api.openswarm.test" + settings.openswarm_bearer_token = "test-pro-token" # what a real Pro user carries with patch("backend.apps.nine_router.is_running", return_value=True), \ patch("backend.apps.nine_router.get_providers", new=AsyncMock(return_value=[])): # nothing connected From 2301ff7a70ebe05601d4414ad81425461064f41b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 02:41:25 -0700 Subject: [PATCH 015/174] [eric] swarm: read live session from memory first on export, plus full dashboard round-trip test --- backend/apps/swarm/entities/sessions.py | 13 ++++- backend/tests/test_swarm_bundle.py | 78 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py index 805f06fc..cea5dad2 100644 --- a/backend/apps/swarm/entities/sessions.py +++ b/backend/apps/swarm/entities/sessions.py @@ -36,8 +36,17 @@ class SessionExportable: @classmethod def load(cls, local_id: str) -> "SessionExportable | None": - from backend.apps.agents.manager.session.session_store import _load_session_data - d = _load_session_data(local_id) + # Memory first, disk fallback, the same order duplicate_session uses. + # The live session holds the freshest transcript; a disk-only read would + # ship a stale one (missing the latest turns) or drop a just-created + # agent that hasn't flushed yet, so its card vanishes from the bundle. + from backend.apps.agents.agent_manager import agent_manager + sess = agent_manager.sessions.get(local_id) + if sess is not None: + d = sess.model_dump(mode="json") + else: + from backend.apps.agents.manager.session.session_store import _load_session_data + d = _load_session_data(local_id) if d is None: return None return cls(local_id, d.get("name") or "Agent", d) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 9d08a595..b7b822e8 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -237,6 +237,84 @@ def test_session_import_old_bundle_without_transcript(monkeypatch): assert doc["active_branch_id"] == "main" and "main" in doc["branches"] +def test_session_load_prefers_live_memory_over_stale_disk(tmp_path, monkeypatch): + # The freshest transcript lives in memory; a disk-only load would ship a + # stale one. load() must read the live session first, disk only as fallback. + from backend.apps.agents import agent_manager as am + from backend.apps.swarm.entities.sessions import SessionExportable + sdir = tmp_path / "sessions" + sdir.mkdir() + monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir)) + (sdir / "s1.json").write_text(json.dumps( + {"name": "Stale", "messages": [{"id": "old", "role": "user", "content": "old"}]})) + + class FakeSess: + def model_dump(self, mode="json"): + return {"name": "Live", "messages": [ + {"id": "old", "role": "user", "content": "old"}, + {"id": "new", "role": "assistant", "content": "fresh turn"}, + ]} + + monkeypatch.setattr(am.agent_manager, "sessions", {"s1": FakeSess()}) + out = SessionExportable.load("s1").serialize(None) + assert out["name"] == "Live" # not the stale disk copy + assert len(out["messages"]) == 2 # the unflushed turn is included + + +def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, monkeypatch): + # The path the single-session tests missed: a whole dashboard with agent + # cards + a browser card. Both agents (with their transcripts) and the + # browser must survive export -> import. An empty-history import is the bug + # the user hit ("the chats didn't even show up, let alone the history"). + import shutil + from backend.apps.agents import agent_manager as am + import backend.config.paths as paths + sdir = tmp_path / "sessions" + ddir = tmp_path / "dashboards" + sdir.mkdir() + ddir.mkdir() + monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir)) + monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir)) + monkeypatch.setattr(am.agent_manager, "sessions", {}) # nothing live -> disk path + + did, sid1, sid2, bkey = "d1", "sA", "sB", "browser-1" + + def sess(sid, name, text): + return { + "id": sid, "name": name, "status": "completed", "provider": "anthropic", + "model": "sonnet", "mode": "agent", "allowed_tools": [], + "messages": [{"id": "m1", "role": "user", "content": text, "branch_id": "main"}], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": "2026-01-01"}}, + "active_branch_id": "main", "tool_group_meta": {}, "active_mcps": [], "dashboard_id": did, + } + + (sdir / f"{sid1}.json").write_text(json.dumps(sess(sid1, "Agent One", "from one"))) + (sdir / f"{sid2}.json").write_text(json.dumps(sess(sid2, "Agent Two", "from two"))) + (ddir / f"{did}.json").write_text(json.dumps({"id": did, "name": "Board", "layout": { + "cards": {sid1: {"session_id": sid1}, sid2: {"session_id": sid2}}, + "view_cards": {}, + "browser_cards": {bkey: {"browser_id": bkey, "url": "u", "spawned_by": None}}, + "notes": {}, "expanded_session_ids": [sid1], + }})) + + raw, _ = closure.build_bundle(EntityType.dashboard, did) + sandbox, manifest, _w = closure.stage_upload(raw, "board.swarm") + try: + _rt, root_id, _created, _u = closure.commit(sandbox, manifest, []) + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + L = json.loads((ddir / f"{root_id}.json").read_text())["layout"] + assert len(L["cards"]) == 2, "both agent cards must survive import" + assert len(L["browser_cards"]) == 1, "the browser card must survive too" + total_msgs = 0 + for sid in L["cards"]: + doc = json.loads((sdir / f"{sid}.json").read_text()) + total_msgs += len(doc.get("messages") or []) + assert doc["active_mcps"] == [], "import must not grant MCP access" + assert total_msgs == 2, "each agent's transcript must carry through" + + def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): from backend.apps.swarm.entities.dashboards import DashboardExportable from backend.apps.swarm.models import EntityType From 131b3136eb142f485047ecc0a042b5fd7c3832a7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 03:03:28 -0700 Subject: [PATCH 016/174] [eric] sessions: get_all_sessions reads disk too so imported (and post-restart) dashboard cards render instead of blank --- backend/apps/agents/agent_manager.py | 26 +++++++++++++++++++++++--- backend/tests/test_swarm_bundle.py | 8 ++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a12a1e56..ad665e7c 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -4551,9 +4551,29 @@ class AgentManager: } def get_all_sessions(self, dashboard_id: str | None = None) -> list[AgentSession]: - if dashboard_id: - return [s for s in self.sessions.values() if s.dashboard_id == dashboard_id] - return list(self.sessions.values()) + if not dashboard_id: + return list(self.sessions.values()) + # Memory first, then promote any on-disk sessions for this dashboard + # that aren't loaded yet. Imported sessions (and ones not resumed since + # a restart) live on disk but not in memory, so without the disk pass + # their cards render blank, the frontend's AgentCard returns null when + # a card's session is missing from the agents slice. Promoting into + # self.sessions bounds the disk read to once per session per run, like + # resume_session. Mirrors get_browser_agent_children's memory+disk walk. + result = [s for s in self.sessions.values() if s.dashboard_id == dashboard_id] + seen = {s.id for s in result} + for sid, data in _load_all_session_data(): + if sid in seen or data.get("dashboard_id") != dashboard_id: + continue + try: + sess = AgentSession(**data) + except Exception: + logger.warning(f"get_all_sessions: skipping unloadable session {sid}", exc_info=True) + continue + _apply_context_window(sess) + self.sessions[sid] = sess + result.append(sess) + return result def get_session(self, session_id: str) -> Optional[AgentSession]: return self.sessions.get(session_id) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index b7b822e8..8dfef88b 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -314,6 +314,14 @@ def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, mo assert doc["active_mcps"] == [], "import must not grant MCP access" assert total_msgs == 2, "each agent's transcript must carry through" + # The bug behind "the chats didn't even show up": after import the sessions + # are on disk but not in memory, and the dashboard-open fetch + # (get_all_sessions) was memory-only, so the cards rendered blank. The fetch + # must now see the freshly-imported sessions straight off disk. + found = am.agent_manager.get_all_sessions(dashboard_id=root_id) + assert len(found) == 2, f"dashboard-open fetch must see imported agent sessions, got {len(found)}" + assert sum(len(s.messages) for s in found) == 2, "and with their transcripts" + def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): from backend.apps.swarm.entities.dashboards import DashboardExportable From 863fd4f9c5f3f7881e29a0addade3df5fe694191 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 03:33:41 -0700 Subject: [PATCH 017/174] [eric] share: flush the dashboard layout before export so a just-added app/agent card isn't missed (debounced save was stale) --- .../pages/Dashboard/canvas/DashboardCanvas.tsx | 2 ++ .../pages/Dashboard/canvas/DashboardHeader.tsx | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 74d66f04..f15174f2 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -180,6 +180,8 @@ const DashboardCanvas: React.FC = ({ cards={cards} viewCards={viewCards} browserCards={browserCards} + notes={notes} + expandedSessionIds={expandedSessionIds} outputs={outputs} dashboardId={dashboardId} canvasActions={canvas.actions} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx index e1ac5859..8bc684ea 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx @@ -6,10 +6,12 @@ import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import LanguageIcon from '@mui/icons-material/Language'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; import DashboardGlyph from './DashboardGlyph'; import ShareButton from '@/app/components/share/ShareButton'; import type { AgentSession } from '@/shared/state/agentsSlice'; -import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import { saveLayout } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; import type { CanvasActions } from '../hooks/interaction/useCanvasControls'; import { friendlyStatusLabel } from '@/shared/statusLabel'; @@ -20,6 +22,8 @@ interface DashboardHeaderProps { cards: Record; viewCards: Record; browserCards: Record; + notes: Record; + expandedSessionIds: string[]; outputs: Record; dashboardId: string | undefined; canvasActions: CanvasActions; @@ -41,12 +45,15 @@ const DashboardHeader: React.FC = ({ cards, viewCards, browserCards, + notes, + expandedSessionIds, outputs, dashboardId, canvasActions, onHighlightCard, }) => { const c = useClaudeTokens(); + const dispatch = useAppDispatch(); const [expanded, setExpanded] = useState(false); const containerRef = useRef(null); @@ -158,6 +165,13 @@ const DashboardHeader: React.FC = ({ { + // Layout saves are debounced, so a just-added app/agent card may + // not be on disk yet. The export reads disk, flush the live + // layout now so Share captures the current board, not a stale one. + if (!dashboardId) return; + dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds })); + }} /> )} From d2d0ce74aafaa9610f6c3d6907d7c7550d2a141e Mon Sep 17 00:00:00 2001 From: Aidan Date: Tue, 16 Jun 2026 03:44:40 -0700 Subject: [PATCH 018/174] [aidan] ux/app-builder-dashboard: improve app builder dashboard (#89) * [aidan] ux: when agent creates app always opens in dashboard * [aidan] enhancement: app editing ui mimicing browseragents * [aidan] ui/ux: when building app window appears immediately * [aidan]: app view spacing dashboard mimicing browser * [aidan] ux: app error agent loop * [aidan] fix: remove duplicate logic --- backend/apps/agents/agent_manager.py | 99 ++++++++++++++++--- backend/apps/outputs/outputs.py | 27 +++++ backend/apps/outputs/runtime.py | 37 ++++++- .../src/app/components/ErrorBoundary.tsx | 26 +++++ .../webapp_template/frontend/src/index.tsx | 58 +++++++++-- .../frontend/src/vite-env.d.ts | 7 ++ .../webapp_template/frontend/vite.config.ts | 8 +- .../Dashboard/cards/DashboardViewCard.tsx | 99 +++++++++++++++++++ .../Dashboard/geometry/dashboardTethers.ts | 76 +++++++++++--- .../hooks/lifecycle/useDashboardLifecycle.ts | 43 ++++++++ .../hooks/state/useDashboardController.ts | 2 + .../src/shared/state/dashboardLayoutSlice.ts | 66 +++++++++++-- frontend/src/shared/ws/WebSocketManager.ts | 25 +++-- 13 files changed, 516 insertions(+), 57 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index ad665e7c..c5fe744e 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -81,6 +81,11 @@ logger = logging.getLogger(__name__) os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") +p_VIEW_BUILDER_RENDER_MAX_RETRIES = 2 +p_view_builder_render_retry_counts: dict[str, int] = {} +p_view_builder_dirty_sessions: set[str] = set() + + def _apply_context_window(session, settings=None) -> None: """Set session.context_window from the registry for its (provider, model). @@ -966,26 +971,38 @@ class AgentManager: except Exception: content = str(raw_response) - # When the agent writes/edits a file inside a live App - # Builder workspace, surface any build-server errors - # (vite/babel/tsc/uvicorn) that landed in the runtime's - # stderr in the moments after the write. Without this the - # agent walks away from broken JSX, the iframe shows a red - # overlay, and the user has to copy-paste the error back. - # ~400ms gives vite's file watcher + babel parse enough - # time to react; the post_tool_hook runs once per tool so - # the added latency is acceptable for the win. hook_tool_name_for_errors = input_data.get("tool_name", "") - if hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit"): - tool_in = input_data.get("tool_input") or {} - file_path = tool_in.get("file_path") or tool_in.get("path") or "" + wrote_files = hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit") + tool_in = input_data.get("tool_input") or {} + file_path = tool_in.get("file_path") or tool_in.get("path") or "" + wrote_frontend_file = wrote_files and "/frontend/" in file_path + installed_pkg = False + if hook_tool_name_for_errors == "Bash": + bash_in = input_data.get("tool_input") or {} + cmd = (bash_in.get("command") or "").lower() + installed_pkg = any(s in cmd for s in ( + "npm install", "npm i ", "npm uninstall", "npm ci", + "pnpm add", "pnpm install", "pnpm remove", + "yarn add", "yarn install", "yarn remove", + )) + + if session.mode == "view-builder" and (wrote_frontend_file or installed_pkg): + p_view_builder_dirty_sessions.add(session.id) + try: + from backend.apps.outputs.runtime import ( + manager as outputs_runtime_manager, + ) + outputs_runtime_manager.reset_render_state_for_workspace(session.id) + except Exception: + pass + elif wrote_files: if file_path: try: await asyncio.sleep(0.4) from backend.apps.outputs.runtime import ( - manager as _outputs_runtime_manager, + manager as outputs_runtime_manager, ) - errs = _outputs_runtime_manager.drain_errors_for_path(file_path) + errs = outputs_runtime_manager.drain_errors_for_path(file_path) except Exception: errs = [] if errs: @@ -1532,6 +1549,59 @@ class AgentManager: if len(_stderr_buffer) > 500: del _stderr_buffer[:250] + async def stop_hook(input_data, tool_use_id, context): + """End-of-turn render gate for App Builder sessions. Reads the + browser-reported render-state of the preview; if the app fails + to render, blocks with the error so the agent fixes it, up to + MAX_RETRIES then lets the stop through.""" + if session.mode != "view-builder": + return {} + if session.id not in p_view_builder_dirty_sessions: + return {} + from backend.apps.outputs.runtime import ( + manager as outputs_runtime_manager, + ) + if outputs_runtime_manager.get(session.id) is None: + return {} + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + waited = 0.0 + while state is None and waited < 5.0: + await asyncio.sleep(0.25) + waited += 0.25 + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + + if state != "error": + p_view_builder_render_retry_counts.pop(session.id, None) + p_view_builder_dirty_sessions.discard(session.id) + return {} + + attempts = p_view_builder_render_retry_counts.get(session.id, 0) + if attempts >= p_VIEW_BUILDER_RENDER_MAX_RETRIES: + logger.warning( + "view-builder preview still failing after %s attempts for session %s; allowing stop", + attempts, session.id, + ) + p_view_builder_render_retry_counts.pop(session.id, None) + p_view_builder_dirty_sessions.discard(session.id) + return {} + + p_view_builder_render_retry_counts[session.id] = attempts + 1 + logger.info( + "view-builder render block (attempt %s/%s) for session %s", + attempts + 1, p_VIEW_BUILDER_RENDER_MAX_RETRIES, session.id, + ) + trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text + return { + "decision": "block", + "reason": ( + f"The preview failed to render (attempt {attempts + 1}/" + f"{p_VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n" + f"{trimmed}\n\n" + "Fix this so the app renders before finishing; the user " + "currently sees an error instead of the app." + ), + } + options_kwargs = { "model": resolved_model, # 64 MB ceiling on the SDK <-> CLI JSON-RPC channel. The @@ -1547,6 +1617,7 @@ class AgentManager: "hooks": { "PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])], "PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])], + "Stop": [HookMatcher(matcher=None, hooks=[stop_hook])], }, "allowed_tools": effective_allowed, "disallowed_tools": effective_disallowed, diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 27923c7e..3b2d241e 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -454,6 +454,33 @@ async def runtime_get_status(workspace_id: str): return _runtime_status_payload(workspace_id) +@outputs.router.post("/workspace/{workspace_id}/runtime/report-error") +async def runtime_report_error(workspace_id: str, body: dict): + from backend.apps.outputs.runtime import manager as runtime_manager + rt = runtime_manager.get(workspace_id) + if rt is None: + return {"ok": False, "recorded": 0} + message = (body.get("message") or "").strip() + component_stack = (body.get("componentStack") or "").strip() + if not message: + return {"ok": False, "recorded": 0} + composed = message + if component_stack: + composed = f"{composed}\n{component_stack}" + rt.set_render_error(composed) + return {"ok": True, "recorded": 1} + + +@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready") +async def runtime_report_ready(workspace_id: str): + from backend.apps.outputs.runtime import manager as runtime_manager + rt = runtime_manager.get(workspace_id) + if rt is None: + return {"ok": False} + rt.set_render_ok() + return {"ok": True} + + @outputs.router.post("/shutdown-all") async def runtime_shutdown_all(): """Reap every workspace subprocess. Electron POSTs this during diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 77f5fe64..b83b9abd 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -99,6 +99,8 @@ class AppRuntime: # vite/babel/uvicorn errors in its next turn and can self-fix # instead of leaving the user with a red iframe overlay. self.recent_errors: deque[str] = deque(maxlen=_RECENT_ERRORS_MAX) + self.render_state: Optional[str] = None + self.render_error_text: str = "" self._stdout_task: Optional[asyncio.Task] = None self._stderr_task: Optional[asyncio.Task] = None self._wait_task: Optional[asyncio.Task] = None @@ -113,6 +115,18 @@ class AppRuntime: self.recent_errors.clear() return out + def set_render_ok(self) -> None: + self.render_state = "ok" + self.render_error_text = "" + + def set_render_error(self, text: str) -> None: + self.render_state = "error" + self.render_error_text = (text or "").strip() + + def reset_render_state(self) -> None: + self.render_state = None + self.render_error_text = "" + @property def running(self) -> bool: return self.process is not None and self.process.returncode is None @@ -460,13 +474,16 @@ class AppRuntime: pass def _maybe_capture_error(self, text: str) -> None: - """If a stderr/stdout line matches a known build-error pattern, - record it for the next agent-tool drain. Tests every line , - cheap (single regex search) and only the matching ones land in - the buffer.""" if _ERROR_PATTERNS.search(text): self.recent_errors.append(text.rstrip()) + def p_maybe_capture_render_beacon(self, text: str) -> None: + if "[openswarm:app-ready]" in text: + self.set_render_ok() + elif "[openswarm:app-error]" in text: + idx = text.index("[openswarm:app-error]") + len("[openswarm:app-error]") + self.set_render_error(text[idx:].strip()) + async def _pipe_stream(self, stream: Optional[asyncio.StreamReader], name: str) -> None: if stream is None: return @@ -480,6 +497,7 @@ class AppRuntime: self._broadcast(LogLine(name, text)) if name == "stderr" or name == "stdout": self._maybe_capture_error(text) + self.p_maybe_capture_render_beacon(text) except Exception: logger.exception("log pipe error (%s) for %s", name, self.workspace_id) @@ -638,6 +656,17 @@ class AppRuntimeManager: return rt.drain_errors() return [] + def get_render_state_for_workspace(self, workspace_id: str) -> tuple[Optional[str], str]: + rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) + if rt is None: + return None, "" + return rt.render_state, rt.render_error_text + + def reset_render_state_for_workspace(self, workspace_id: str) -> None: + rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) + if rt is not None: + rt.reset_render_state() + async def restart(self, workspace_id: str, workspace_path: Optional[str] = None) -> Optional[AppRuntime]: rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) if rt is None: diff --git a/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx index 6fdacd98..ced4433d 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx @@ -35,8 +35,34 @@ class ErrorBoundary extends React.Component { + if (!window.__openswarm_rendered) reportRender(false, e.message || String(e.error ?? e)); +}); +window.addEventListener('unhandledrejection', (e) => { + if (!window.__openswarm_rendered) reportRender(false, String(e.reason ?? e)); +}); + +if (import.meta.hot) { + const hot = import.meta.hot; + hot.on('vite:error', (payload) => { + const err = payload?.err; + reportRender(false, err?.message || err?.plugin || 'vite error'); + }); + // Re-assert the real state after every HMR update: if the ErrorBoundary is + // still showing its fallback, report the error again (an unrelated edit that + // didn't fix it must not flip the gate to "ready"); otherwise report ready. + hot.on('vite:afterUpdate', () => { + if (window.__openswarm_render_failed) { + reportRender(false, window.__openswarm_last_error || 'app still failing to render'); + } else { + reportRender(true); + } + }); +} + const rootEl = document.getElementById('root'); if (!rootEl) { - console.error('[App] FATAL: #root element not found in DOM'); + console.error('[openswarm:app-error]', '#root element not found in DOM'); } else { // Wrap Main in an ErrorBoundary so any runtime crash from agent // edits (missing imports, hook-rules violations, etc.) shows a // readable error card in the preview pane instead of unmounting - // to a blank screen. The boundary also forwards the error via - // console.error + postMessage so the agent sees it on its next - // turn. + // to a blank screen. The boundary forwards the error via + // console.error + postMessage so the agent sees it on its next turn. createRoot(rootEl).render(
, ); - console.log('[App] React root mounted'); + // Defer a frame so a synchronous render crash sets __openswarm_render_failed + // (via the boundary) before we'd wrongly report ready. + requestAnimationFrame(() => { + if (window.__openswarm_render_failed) return; + reportRender(true); + }); } diff --git a/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts b/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts index f07ec82e..97d2dc49 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts +++ b/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts @@ -1,2 +1,9 @@ /// /// + +// Render-health beacon flags the OpenSwarm App Builder host reads off the preview. +interface Window { + __openswarm_rendered?: boolean; + __openswarm_render_failed?: boolean; + __openswarm_last_error?: string; +} diff --git a/backend/apps/outputs/webapp_template/frontend/vite.config.ts b/backend/apps/outputs/webapp_template/frontend/vite.config.ts index ae0498af..086c468f 100644 --- a/backend/apps/outputs/webapp_template/frontend/vite.config.ts +++ b/backend/apps/outputs/webapp_template/frontend/vite.config.ts @@ -52,7 +52,13 @@ export default defineConfig(({ mode }) => { plugins: [ react(), Pages({ dirs: 'src/pages', extensions: ['tsx'] }), - terminal({ console: 'terminal', output: ['terminal', 'console'] }), + // vite-plugin-terminal provides a `virtual:terminal/console` module + // that only exists in dev; loading it during `vite build` errors + // out, so the End-of-turn build-verify gate would fail on every + // brand-new workspace. + ...(mode === 'development' + ? [terminal({ console: 'terminal', output: ['terminal', 'console'] })] + : []), ], resolve: { alias: { diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 6176dfe5..7abc7504 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; import Box from '@mui/material/Box'; +import Fade from '@mui/material/Fade'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; @@ -96,6 +97,32 @@ const DashboardViewCard: React.FC = ({ const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); + // Reload the preview when the session finishes a turn: React holds the + // ErrorBoundary's snag page until a reload, so without this the user keeps + // seeing the old error even after the agent fixed it. The overlay lingers + // through the reload (finishing) so the stale page never flashes. + const linkedStatus = useAppSelector( + (s) => (output.session_id ? s.agents.sessions[output.session_id]?.status : undefined), + ); + const [finishing, setFinishing] = useState(false); + const wasBuildingRef = useRef(false); + const finishTimerRef = useRef(null); + useEffect(() => { + const building = linkedStatus === 'running' || linkedStatus === 'waiting_approval'; + if (wasBuildingRef.current && !building) { + previewRef.current?.reload(); + setFinishing(true); + if (finishTimerRef.current) clearTimeout(finishTimerRef.current); + finishTimerRef.current = window.setTimeout(() => setFinishing(false), 1200); + } + wasBuildingRef.current = building; + }, [linkedStatus]); + useEffect(() => () => { + if (finishTimerRef.current) clearTimeout(finishTimerRef.current); + }, []); + const showBuildingOverlay = linkedStatus === 'running' + || linkedStatus === 'waiting_approval' || finishing; + const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); const [isDragging, setIsDragging] = useState(false); @@ -428,6 +455,7 @@ const DashboardViewCard: React.FC = ({ interactive={interactive} onAppClicked={() => dispatch(setActiveViewCardId(output.id))} /> + {/* Resize handles */} @@ -502,6 +530,49 @@ const DashboardViewCard: React.FC = ({ export default React.memo(DashboardViewCard); +// Calm overlay shown while the App Builder chat that owns this output is +// actively editing it (and through the post-turn reload). Hides whatever +// transient half-broken state the agent might be writing through so the +// user sees "Building..." instead of an error iframe. Fades in/out. +const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => { + const c = useClaudeTokens(); + return ( + + + + + Buildingโ€ฆ + + + + ); +}; + // Old-mode outputs render the legacy serve URL; new-mode webapp_template outputs attach to a runtime and point the webview at Vite once frontend_url arrives. const DashboardOutputPreview: React.FC<{ previewRef: React.Ref; @@ -525,6 +596,33 @@ const DashboardOutputPreview: React.FC<{ isNewMode, }); + // Declared above every early-return below so React's hook order stays + // stable; moving it below would trigger "Rendered more hooks than during + // the previous render." + const handleConsoleMessage = useCallback((level: string, text: string) => { + if (!text || !workspaceId) return; + const tok = getAuthToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (tok) headers.Authorization = `Bearer ${tok}`; + if (text.includes('[openswarm:app-ready]')) { + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready`, { + method: 'POST', headers, + }).catch(() => {}); + return; + } + if (level !== 'error' || !text.includes('[openswarm:app-error]')) return; + const idx = text.indexOf('[openswarm:app-error]'); + const tail = text.slice(idx + '[openswarm:app-error]'.length).trim(); + const firstNewline = tail.indexOf('\n'); + const message = firstNewline >= 0 ? tail.slice(0, firstNewline).trim() : tail; + const componentStack = firstNewline >= 0 ? tail.slice(firstNewline + 1).trim() : ''; + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error`, { + method: 'POST', + headers, + body: JSON.stringify({ message, componentStack }), + }).catch(() => {}); + }, [workspaceId]); + // An orphaned record (files deleted on disk) used to render the raw 404 JSON // inside the card, or spin on "Starting preview" forever; probe once instead. const [filesMissing, setFilesMissing] = useState(false); @@ -610,6 +708,7 @@ const DashboardOutputPreview: React.FC<{ frontendCode={output.files?.['index.html'] ?? ''} inputData={inputData} backendResult={backendResult} + onConsoleMessage={handleConsoleMessage} interactive={interactive} onAppClicked={onAppClicked} /> diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 80440641..f28850c1 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -1,7 +1,8 @@ import { useMemo, type RefObject } from 'react'; -import type { CardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice'; import { EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; import type { AgentSession } from '@/shared/state/agentsSlice'; +import type { Output } from '@/shared/state/outputsSlice'; const ELBOW_RADIUS = 16; @@ -60,6 +61,8 @@ interface UseTethersArgs { glowingBrowserCards: Record; cards: Record; browserCards: Record; + viewCards: Record; + outputs: Record; expandedSessionIds: string[]; liveDragInfo: LiveDragInfo | null; measuredHeightsRef: RefObject>; @@ -72,6 +75,8 @@ export function useTethers({ glowingBrowserCards, cards, browserCards, + viewCards, + outputs, expandedSessionIds, liveDragInfo, measuredHeightsRef, @@ -118,21 +123,25 @@ export function useTethers({ }; }).filter(Boolean) as Tether[]; - function browserTether( - browserId: string, + // One tether builder for both browser and view cards: the anchor-pairing + // and elbow/vertical path are identical; only the destination card map and + // the key prefix differ, so the resolved dst card is passed in. + function cardTether( + dst: { x: number; y: number; width: number; height: number } | undefined, + dstId: string, sourceId: string, - fading: boolean, + key: string, label: string, + fading: boolean, ): Tether | null { const src = cards[sourceId]; - const dst = browserCards[browserId]; if (!src || !dst) return null; let srcX = src.x, srcY = src.y; let dstX = dst.x, dstY = dst.y; if (liveDragInfo) { if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } - if (liveDragInfo.cardId === browserId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + if (liveDragInfo.cardId === dstId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } const srcMeasured = measuredHeightsRef.current![sourceId]; @@ -200,7 +209,7 @@ export function useTethers({ const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; return { - key: `browser-${browserId}`, + key, path: pathD, labelX, labelY, @@ -209,9 +218,16 @@ export function useTethers({ }; } - const glowTethers = new Map>(); + const glowTethers = new Map>(); for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) { - const t = browserTether(browserId, sourceId, fading, label || ''); + const t = cardTether( + browserCards[browserId], + browserId, + sourceId, + `browser-${browserId}`, + label || '', + fading, + ); if (t) glowTethers.set(browserId, t); } @@ -220,15 +236,51 @@ export function useTethers({ if (s.status !== 'running' && s.status !== 'waiting_approval') continue; if (!s.browser_id || !s.parent_session_id) continue; if (glowTethers.has(s.browser_id)) continue; - const t = browserTether(s.browser_id, s.parent_session_id, false, ''); + const t = cardTether( + browserCards[s.browser_id], + s.browser_id, + s.parent_session_id, + `browser-${s.browser_id}`, + '', + false, + ); if (t) glowTethers.set(s.browser_id, t); } const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[]; - return [...agentTethers, ...browserTethers]; + // Index outputs by their owning session so the per-session lookup below + // doesn't scan the whole outputs map for every view-builder chat. + const outputsBySession = new Map(); + for (const o of Object.values(outputs)) { + if (!o.session_id) continue; + const arr = outputsBySession.get(o.session_id); + if (arr) arr.push(o.id); else outputsBySession.set(o.session_id, [o.id]); + } + + const viewTethers: Tether[] = []; + for (const s of sessionList) { + if (s.mode !== 'view-builder') continue; + if (s.status !== 'running' && s.status !== 'waiting_approval') continue; + const outIds = outputsBySession.get(s.id); + if (!outIds) continue; + for (const outputId of outIds) { + if (!viewCards[outputId]) continue; + const t = cardTether( + viewCards[outputId], + outputId, + s.id, + `view-${outputId}`, + 'Editing', + false, + ); + if (t) viewTethers.push(t); + } + } + + return [...agentTethers, ...browserTethers, ...viewTethers]; // measuredHeightsTick re-runs the memo once ResizeObserver reports a new // height after a collapse (the ref read is invisible to the dep checker). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); } diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index b0b51f65..df6d4237 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -12,6 +12,7 @@ import { fetchLayout, reconcileSessions, addBrowserCard, + addViewCard, resetLayout, removeViewCard, clearPendingFocusBrowserId, @@ -253,6 +254,48 @@ export function useDashboardLifecycle({ } }, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]); + // On first load after outputs settle, snapshot every existing Output id as + // "already accounted for." Any output that ARRIVES later (typically the + // agent:output_upserted WS broadcast the backend fires the instant a + // view-builder session is seeded, at session start) whose session_id points + // at a view-builder chat on this dashboard gets a view card dropped on the + // canvas right away. Per-mount tracked so a manual close after auto-open + // stays closed. Prior approach keyed off a pending-set populated inside + // launchAndSendFirstMessage.then(): the WS upsert won the race and the + // effect saw an empty set, so the card didn't pop until the session-end + // meta-sync re-broadcast. + const autoOpenedOutputsRef = useRef>(new Set()); + const outputsSnapshottedRef = useRef(false); + useEffect(() => { + if (!layoutInitialized || !outputsLoaded) return; + if (!outputsSnapshottedRef.current) { + for (const oid of Object.keys(outputs)) autoOpenedOutputsRef.current.add(oid); + outputsSnapshottedRef.current = true; + return; + } + for (const output of Object.values(outputs)) { + if (autoOpenedOutputsRef.current.has(output.id)) continue; + const sid = output.session_id; + if (!sid) continue; + const sess = sessions[sid]; + if (!sess || sess.mode !== 'view-builder') continue; + if (sess.dashboard_id !== dashboardId) continue; + autoOpenedOutputsRef.current.add(output.id); + if (viewCards[output.id]) continue; + dispatch(addViewCard({ outputId: output.id, expandedSessionIds, parentSessionId: sid })); + const outputId = output.id; + setTimeout(() => { + const vc = store.getState().dashboardLayout.viewCards[outputId]; + if (!vc) return; + const rects = [{ x: vc.x, y: vc.y, width: vc.width, height: vc.height }]; + const ac = store.getState().dashboardLayout.cards[sid]; + if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height }); + canvasActions.fitToCards(rects, 1.15, true); + handleHighlightCard(outputId); + }, 200); + } + }, [layoutInitialized, outputsLoaded, outputs, sessions, viewCards, dashboardId, expandedSessionIds, dispatch, canvasActions, handleHighlightCard]); + const namedOnFirstMessageRef = useRef(null); useEffect(() => { if (!dashboardId || !layoutInitialized) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 4184dede..fc425a19 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -271,6 +271,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { glowingBrowserCards, cards, browserCards, + viewCards, + outputs, expandedSessionIds, liveDragInfo, measuredHeightsRef, diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 3e36cd1b..6cce568c 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -38,6 +38,7 @@ export interface ViewCardPosition { width: number; height: number; zOrder: number; + parent_session_id?: string | null; } export interface BrowserTab { @@ -199,6 +200,11 @@ interface Rect { h: number; } +interface CardPlacementExclusion { + type: CardType; + id: string; +} + function rectsOverlap(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } @@ -206,20 +212,25 @@ function rectsOverlap(a: Rect, b: Rect): boolean { function collectOccupiedRects( state: DashboardLayoutState, expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, ): Rect[] { const expanded = new Set(expandedSessionIds); const rects: Rect[] = []; for (const c of Object.values(state.cards)) { + if (exclude?.type === 'agent' && exclude.id === c.session_id) continue; const h = expanded.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height; rects.push({ x: c.x, y: c.y, w: c.width, h }); } for (const c of Object.values(state.viewCards)) { + if (exclude?.type === 'view' && exclude.id === c.output_id) continue; rects.push({ x: c.x, y: c.y, w: c.width, h: c.height }); } for (const c of Object.values(state.browserCards)) { + if (exclude?.type === 'browser' && exclude.id === c.browser_id) continue; rects.push({ x: c.x, y: c.y, w: c.width, h: c.height }); } for (const n of Object.values(state.notes)) { + if (exclude?.type === 'note' && exclude.id === n.note_id) continue; rects.push({ x: n.x, y: n.y, w: n.width, h: n.height }); } return rects; @@ -312,6 +323,36 @@ export function findOpenSpotNear( return findOpenGridCell(occupiedRects, newW, newH); } +export function placeInParentColumn( + state: DashboardLayoutState, + parentSessionId: string | null | undefined, + newW: number, + newH: number, + expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, +): { x: number; y: number } { + const rects = collectOccupiedRects(state, expandedSessionIds, exclude); + const parentCard = parentSessionId ? state.cards[parentSessionId] : null; + if (!parentCard) { + return findOpenGridCell(rects, newW, newH); + } + + const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; + const columnCards = [ + ...Object.values(state.browserCards).filter( + (c) => !(exclude?.type === 'browser' && exclude.id === c.browser_id), + ), + ...Object.values(state.viewCards).filter( + (c) => !(exclude?.type === 'view' && exclude.id === c.output_id), + ), + ].filter((c) => Math.abs(c.x - targetX) < 50); + const targetY = columnCards.length > 0 + ? Math.max(...columnCards.map((c) => c.y + c.height)) + GRID_GAP + : parentCard.y; + + return findOpenSpotNear(targetX, targetY, rects, newW, newH); +} + // Reconnect-refetch merge: ADD only the cards the snapshot carries that the // client is missing (e.g. a spawned browser whose broadcast was lost in a // socket gap), collision-resolving each against the live layout so a recovered @@ -522,27 +563,38 @@ const dashboardLayoutSlice = createSlice({ addViewCard(state, action: PayloadAction<{ outputId: string; expandedSessionIds?: string[]; + parentSessionId?: string | null; x?: number; y?: number; width?: number; height?: number; }>) { - const { outputId, expandedSessionIds, x, y, width, height } = action.payload; + const { outputId, expandedSessionIds, parentSessionId, x, y, width, height } = action.payload; if (state.viewCards[outputId]) return; + const w = width || DEFAULT_VIEW_CARD_W; + const h = height || DEFAULT_VIEW_CARD_H; let posX: number, posY: number; if (x != null && y != null) { posX = x; posY = y; } else { - const rects = collectOccupiedRects(state, expandedSessionIds); - const pos = findOpenGridCell(rects, DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H); - posX = pos.x; - posY = pos.y; + const parentCard = parentSessionId ? state.cards[parentSessionId] : null; + if (parentCard) { + const pos = placeInParentColumn(state, parentSessionId, w, h, expandedSessionIds); + posX = pos.x; + posY = pos.y; + } else { + const rects = collectOccupiedRects(state, expandedSessionIds); + const pos = findOpenGridCell(rects, w, h); + posX = pos.x; + posY = pos.y; + } } state.viewCards[outputId] = { output_id: outputId, x: posX, y: posY, - width: width || DEFAULT_VIEW_CARD_W, - height: height || DEFAULT_VIEW_CARD_H, + width: w, + height: h, zOrder: state.nextZOrder++, + parent_session_id: parentSessionId || null, }; }, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 258f62e4..dd90844f 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -23,7 +23,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { getAuthToken } from '../config'; @@ -762,21 +762,20 @@ class WebSocketManager { const parentId = data.parent_session_id; if (parentId) { const layoutState = store.getState().dashboardLayout; - const parentCard = layoutState.cards[parentId]; - if (parentCard) { - const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; - let targetY = parentCard.y; - const columnCards = Object.values(layoutState.browserCards).filter( - (c) => Math.abs(c.x - targetX) < 50 && c.browser_id !== data.browser_card.browser_id, + const browserCard = layoutState.browserCards[data.browser_card.browser_id]; + if (layoutState.cards[parentId] && browserCard) { + const pos = placeInParentColumn( + layoutState, + parentId, + browserCard.width, + browserCard.height, + undefined, + { type: 'browser', id: browserCard.browser_id }, ); - if (columnCards.length > 0) { - const lowestBottom = Math.max(...columnCards.map((c) => c.y + c.height)); - targetY = lowestBottom + GRID_GAP; - } store.dispatch(setBrowserCardPosition({ browserId: data.browser_card.browser_id, - x: targetX, - y: targetY, + x: pos.x, + y: pos.y, })); store.dispatch(setGlowingBrowserCards({ browserIds: [data.browser_card.browser_id], From 1f86a66b872c48297a06605e6ce58f5cdf078460 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 03:46:29 -0700 Subject: [PATCH 019/174] [eric] swarm: remap view-card parent_session_id on share (new #89 field would import dangling) --- backend/apps/swarm/entities/dashboards.py | 14 ++++++++++++-- backend/tests/test_swarm_bundle.py | 21 +++++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py index 71ee3d3f..7bf4aba4 100644 --- a/backend/apps/swarm/entities/dashboards.py +++ b/backend/apps/swarm/entities/dashboards.py @@ -39,7 +39,13 @@ class DashboardExportable: for oid, card in (layout.get("view_cards") or {}).items(): bid = ctx.bundle_id_for(EntityType.app, oid) if bid: - view_cards[bid] = {**card, "output_id": bid} + # parent_session_id tethers the app card to the agent that built it; + # it's a session id, so it remaps like spawned_by on browser cards. + parent = card.get("parent_session_id") + view_cards[bid] = { + **card, "output_id": bid, + "parent_session_id": ctx.bundle_id_for(EntityType.session, parent) if parent else None, + } browser_cards = {} for bkey, card in (layout.get("browser_cards") or {}).items(): c = dict(card) @@ -78,7 +84,11 @@ class DashboardExportable: for bid, card in (layout.get("view_cards") or {}).items(): noid = remap.local(bid) if noid: - view_cards[noid] = {**card, "output_id": noid} + parent = card.get("parent_session_id") + view_cards[noid] = { + **card, "output_id": noid, + "parent_session_id": remap.local(parent) if parent else None, + } browser_cards = {} for _bkey, card in (layout.get("browser_cards") or {}).items(): nbid = "browser-" + uuid4().hex[:10] diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 8dfef88b..fe23d60a 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -333,13 +333,15 @@ def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): data = {"name": "D", "layout": { "cards": {"S": {"session_id": "S", "x": 1}}, - "view_cards": {"A": {"output_id": "A", "x": 2}}, + "view_cards": {"A": {"output_id": "A", "x": 2, "parent_session_id": "S"}}, "browser_cards": {"b1": {"browser_id": "b1", "url": "u", "spawned_by": "S"}}, "expanded_session_ids": ["S"], }} L = DashboardExportable("d1", "D", data).serialize(Ctx())["layout"] assert L["cards"]["SBID"]["session_id"] == "SBID" assert L["view_cards"]["ABID"]["output_id"] == "ABID" + # the app card's tether to its builder agent is a session id, so it remaps too + assert L["view_cards"]["ABID"]["parent_session_id"] == "SBID" assert L["browser_cards"]["b1"]["spawned_by"] == "SBID" assert L["expanded_session_ids"] == ["SBID"] @@ -356,14 +358,19 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch): remap.assign("ABID", "newapp") payload = {"name": "D", "layout": { "cards": {"SBID": {"session_id": "SBID"}}, - "view_cards": {"ABID": {"output_id": "ABID"}}, + "view_cards": { + "ABID": {"output_id": "ABID", "parent_session_id": "SBID"}, + "ABID2": {"output_id": "ABID2", "parent_session_id": "GONE"}, + }, "browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID"}}, "expanded_session_ids": ["SBID", "ORPHAN"], }} + remap.assign("ABID2", "newapp2") did = dmod.DashboardExportable.import_(payload, {}, remap) L = written[did]["layout"] assert L["cards"]["newsess"]["session_id"] == "newsess" - assert "newapp" in L["view_cards"] + assert L["view_cards"]["newapp"]["parent_session_id"] == "newsess" + assert L["view_cards"]["newapp2"]["parent_session_id"] is None # parent not in bundle assert list(L["browser_cards"].values())[0]["spawned_by"] == "newsess" assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped @@ -402,7 +409,11 @@ def test_dashboard_remap_invariant_generative(monkeypatch): layout = { "cards": {s: {"session_id": s, "x": rng.randint(0, 9)} for s in sess}, - "view_cards": {a: {"output_id": a} for a in apps}, + "view_cards": { + a: {"output_id": a, + "parent_session_id": (rng.choice(sess + ["ORPHAN"]) if sess and rng.random() < 0.7 else None)} + for a in apps + }, "browser_cards": { f"b{i}": {"browser_id": f"b{i}", "url": "u", "spawned_by": (rng.choice(sess) if sess and rng.random() < 0.7 else None)} @@ -430,6 +441,8 @@ def test_dashboard_remap_invariant_generative(monkeypatch): assert cid not in forbidden and card["session_id"] == cid for oid, card in L["view_cards"].items(): assert oid not in forbidden and card["output_id"] == oid + p = card["parent_session_id"] + assert p is None or (p in set(fresh_sess.values()) and p not in forbidden) assert set(L["expanded_session_ids"]) <= set(fresh_sess.values()) for card in L["browser_cards"].values(): assert card["spawned_by"] is None or card["spawned_by"] in set(fresh_sess.values()) From c9caaf8a8c19977e1d04efda36c90e3febdb08f4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:00:04 -0700 Subject: [PATCH 020/174] [eric] permissions: default Bash to always_allow (seed + one-time lift) so shell commands stop prompting --- backend/apps/tools_lib/tools_lib.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index 85723255..1a7c60bf 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -51,11 +51,16 @@ async def tools_lib_lifespan(): tools_lib = SubApp("tools", tools_lib_lifespan) -# Bash defaults to "ask" because it can execute untrusted text from MCP tool -# outputs (Gmail, WebFetch); every other built-in is sandboxed by domain. -# Must match agent_manager._DEFAULTS so the Settings UI and the agent agree -# on what "no policy set" means. -_DEFAULT_BUILTIN_POLICIES = {"Bash": "ask"} +# Every built-in seeds to always_allow for a frictionless run. The agent's +# runtime guards in agent_manager (catastrophic-command match, OS-scheduling, +# sensitive-path gate) STILL force a prompt for the dangerous shapes even on +# always_allow, so the poisoned-MCP-output -> destructive-command case is +# still caught. Must match agent_manager._DEFAULTS (empty -> always_allow) so +# the Settings UI and the agent agree on what "no policy set" means. +_DEFAULT_BUILTIN_POLICIES: dict[str, str] = {} + +# One-time marker: older installs seeded Bash="ask"; we lift them once. +_BASH_AUTOALLOW_MARKER = os.path.join(DATA_DIR, ".bash_autoallow_migrated") def _ensure_default_permissions() -> None: @@ -72,6 +77,17 @@ def _ensure_default_permissions() -> None: for t in BUILTIN_TOOLS } merged = {**desired, **existing} + # One-time lift: installs seeded under the old default carry Bash="ask"; + # raise them to always_allow once so shell commands stop prompting. The + # marker means a deliberate "ask" set afterward sticks (never re-flipped). + if not os.path.exists(_BASH_AUTOALLOW_MARKER): + if merged.get("Bash") == "ask": + merged["Bash"] = "always_allow" + try: + with open(_BASH_AUTOALLOW_MARKER, "w") as f: + f.write("1") + except OSError: + pass if merged != existing: save_builtin_permissions(merged) From 07ef98f7e52dfb0e71fcb9d4248a9f78253c2b68 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:00:08 -0700 Subject: [PATCH 021/174] [eric] approvals: persist tool policy on Always-approve (set_always_allow through the approval path) --- backend/apps/agents/agent_manager.py | 12 ++++++++++++ backend/apps/agents/agents.py | 1 + backend/apps/agents/core/models.py | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index c5fe744e..46e744d6 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -23,6 +23,7 @@ from backend.apps.tools_lib.tools_lib import ( refresh_airtable_token, refresh_google_token, refresh_hubspot_token, + save_builtin_permissions, save_trusted_sensitive_paths, ) from backend.config.paths import SESSIONS_DIR @@ -788,6 +789,17 @@ class AgentManager: except Exception: logger.exception("Failed to persist trusted sensitive path") + # "Always approve" button: persist the tool's policy so it stops + # prompting. The guards above (sensitive/catastrophic) re-fire even + # on always_allow, so this can't disarm an rm -rf or a key-path write. + if decision.get("behavior") == "allow" and decision.get("set_always_allow"): + try: + perms = load_builtin_permissions() + perms[tool_name] = "always_allow" + save_builtin_permissions(perms) + except Exception: + logger.exception("Failed to persist always-allow for %s", tool_name) + approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000) try: # Append to the session's approval log so a reload diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 96746f02..3a84dae3 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -123,6 +123,7 @@ async def handle_approval(response: ApprovalResponse): "message": response.message, "updated_input": response.updated_input, "trust_pattern": response.trust_pattern, + "set_always_allow": response.set_always_allow, }) return {"ok": True} diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 972e9beb..de1e0832 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -42,6 +42,10 @@ class ApprovalResponse(BaseModel): # (from ApprovalRequest.sensitive_pattern) to disk so future writes # against the same pattern skip the modal. trust_pattern: bool = False + # "Always approve" button: persist this tool's policy to always_allow so + # the same tool stops prompting (the catastrophic/sensitive guards still + # fire, so this can't blanket-approve an rm -rf or a sensitive-path write). + set_always_allow: bool = False class Message(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex) From bb42c62e9d06a2308888d12de75cf03bc83e54f6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:00:13 -0700 Subject: [PATCH 022/174] [eric] approvals: add Always-approve button + make Deny one-click (drop the buggy reason step) --- .../src/app/pages/AgentChat/AgentChat.tsx | 4 +- .../app/pages/AgentChat/shell/ApprovalBar.tsx | 150 +++++++----------- frontend/src/shared/state/agentsSlice.ts | 4 +- 3 files changed, 59 insertions(+), 99 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b85750ab..172c7f21 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -879,8 +879,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (!isDraft) dispatch(updateThinkingLevel({ sessionId: id, level })); }, [id, isDraft, dispatch]); - const handleApprove = (requestId: string, updatedInput?: Record, trustPattern?: boolean) => { - dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput, trustPattern })); + const handleApprove = (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => { + dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput, trustPattern, setAlwaysAllow: alwaysAllow })); }; const handleDeny = (requestId: string, message?: string) => { diff --git a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx index 09daf483..2ffabc62 100644 --- a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx +++ b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx @@ -11,6 +11,7 @@ import FormControlLabel from '@mui/material/FormControlLabel'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import SendIcon from '@mui/icons-material/Send'; import CheckIcon from '@mui/icons-material/Check'; +import DoneAllIcon from '@mui/icons-material/DoneAll'; import CloseIcon from '@mui/icons-material/Close'; import TerminalIcon from '@mui/icons-material/Terminal'; import DescriptionIcon from '@mui/icons-material/Description'; @@ -163,7 +164,7 @@ function getMcpInputSummary(actionName: string, toolInput: Record): interface Props { request: ApprovalRequest; - onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean) => void; + onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => void; onDeny: (requestId: string, message?: string) => void; } @@ -304,7 +305,7 @@ type Answers = Record; export interface QuestionFormProps { request: ApprovalRequest; - onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean) => void; + onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => void; onDeny: (requestId: string, message?: string) => void; compact?: boolean; } @@ -560,8 +561,6 @@ export const QuestionForm: React.FC = ({ request, onApprove, const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => { const c = useClaudeTokens(); - const [denyMessage, setDenyMessage] = useState(''); - const [showDenyInput, setShowDenyInput] = useState(false); const [detailsExpanded, setDetailsExpanded] = useState(false); const [trustPattern, setTrustPattern] = useState(false); @@ -661,26 +660,7 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => /> )} - {showDenyInput && ( - setDenyMessage(e.target.value)} - fullWidth - size="small" - sx={{ - mb: 1.5, - '& .MuiOutlinedInput-root': { - color: c.text.primary, - fontSize: '0.8rem', - '& fieldset': { borderColor: c.border.strong }, - '&.Mui-focused fieldset': { borderColor: c.status.error }, - }, - }} - /> - )} - - + - {showDenyInput ? ( - - ) : ( + {!isSensitive && ( )} + ); @@ -818,28 +798,7 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => - {showDenyInput && ( - - setDenyMessage(e.target.value)} - fullWidth - size="small" - autoFocus - sx={{ - '& .MuiOutlinedInput-root': { - color: c.text.primary, - fontSize: '0.8rem', - '& fieldset': { borderColor: c.border.strong }, - '&.Mui-focused fieldset': { borderColor: c.status.error }, - }, - }} - /> - - )} - - + - {showDenyInput ? ( - - ) : ( - - )} + + ); @@ -911,7 +869,7 @@ interface ToolGroup { interface BatchApprovalBarProps { requests: ApprovalRequest[]; - onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean) => void; + onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => void; onDeny: (requestId: string, message?: string) => void; } @@ -1050,7 +1008,7 @@ interface GroupRowProps { group: ToolGroup; expanded: boolean; onToggle: () => void; - onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean) => void; + onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => void; onDeny: (requestId: string, message?: string) => void; onApproveGroup: () => void; onDenyGroup: () => void; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index d64f52df..7b03fce8 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -406,17 +406,19 @@ export const handleApproval = createAsyncThunk( message, updatedInput, trustPattern, + setAlwaysAllow, }: { requestId: string; behavior: 'allow' | 'deny'; message?: string; updatedInput?: Record; trustPattern?: boolean; + setAlwaysAllow?: boolean; }) => { const res = await fetch(`${AGENTS_API}/approval`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput, trust_pattern: !!trustPattern }), + body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput, trust_pattern: !!trustPattern, set_always_allow: !!setAlwaysAllow }), }); if (!res.ok) { throw new Error(`Approval request failed (${res.status})`); From 98233c1baf7eed9579f88966e45a152ae8e8e617 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:02:20 -0700 Subject: [PATCH 023/174] [eric] share: .swarm drop blast now radiates full-screen and frays to the corners (was a boxed 240px ripple) --- .../src/app/components/share/ImportDigest.tsx | 85 +++++++++++-------- .../app/components/share/ImportEntryPoint.tsx | 2 +- 2 files changed, 52 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/components/share/ImportDigest.tsx b/frontend/src/app/components/share/ImportDigest.tsx index c08ecee8..01faccb6 100644 --- a/frontend/src/app/components/share/ImportDigest.tsx +++ b/frontend/src/app/components/share/ImportDigest.tsx @@ -1,10 +1,10 @@ -// The "digest" flash that plays where you drop a .swarm: an expanding ring of -// brand-tinted dithered pixels, evoking PixelBlast WITHOUT any WebGL. PixelBlast -// is a single shared WebGL2 context (one canvas, reparented) and reusing it here -// would fight an app's loading animation over that one canvas, plus rapid -// WebGL-context churn is the exact thing that crashed the GPU process. So this is -// plain Canvas2D on ONE pooled canvas, and play() refuses to start while a burst -// is already running, so drop-spam can never pile up work. +// The "digest" flash that plays where you drop a .swarm: a brand-tinted pixel +// blast that radiates from the drop point all the way to the corners, thinning +// out and dimming as it travels so the edges dissolve instead of ending in a +// box. Plain Canvas2D on ONE pooled, full-viewport canvas (reusing PixelBlast's +// shared WebGL context would fight an app's loading animation, and WebGL-context +// churn is the exact thing that crashed the GPU process). play() refuses to +// start while a burst is running, so drop-spam can never pile up work. import React, { forwardRef, useImperativeHandle, useRef } from 'react'; export interface DigestHandle { @@ -12,10 +12,10 @@ export interface DigestHandle { play: (x: number, y: number) => boolean; } -const SIZE = 240; -const CELL = 6; -const DURATION = 680; -const RADIUS_MAX = 132; +const CELL = 12; // chunky pixels read as a "blast", and fewer cells = cheap +const DURATION = 820; +const BAND = 110; // wave-front thickness in px; wide enough to feel like a wave +const ALPHA_CAP = 0.62; // keep it a whisper, never a solid flash function dither(gx: number, gy: number): number { const v = Math.sin(gx * 12.9898 + gy * 78.233) * 43758.5453; @@ -35,8 +35,6 @@ const ImportDigest = forwardRef(({ color = '#c const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; busyRef.current = true; - canvas.style.left = `${x - SIZE / 2}px`; - canvas.style.top = `${y - SIZE / 2}px`; canvas.style.opacity = '1'; const finish = () => { @@ -49,35 +47,55 @@ const ImportDigest = forwardRef(({ color = '#c return true; } + const W = window.innerWidth; + const H = window.innerHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); - canvas.width = SIZE * dpr; - canvas.height = SIZE * dpr; + canvas.width = W * dpr; + canvas.height = H * dpr; const ctx = canvas.getContext('2d'); if (!ctx) { finish(); return true; } ctx.scale(dpr, dpr); - const cells = Math.ceil(SIZE / CELL); - const center = SIZE / 2; + + // Reach the farthest corner so the wave actually clears the whole window. + const maxDist = Math.max( + Math.hypot(x, y), Math.hypot(W - x, y), + Math.hypot(x, H - y), Math.hypot(W - x, H - y), + ); + const cols = Math.ceil(W / CELL); + const rows = Math.ceil(H / CELL); const start = performance.now(); const frame = () => { const t = Math.min(1, (performance.now() - start) / DURATION); - const eased = 1 - Math.pow(1 - t, 3); - const ring = eased * RADIUS_MAX; - ctx.clearRect(0, 0, SIZE, SIZE); + const eased = 1 - Math.pow(1 - t, 3); // quick out, like a blast + const ring = eased * (maxDist + BAND); + const ringSq = ring * ring; + const inner = Math.max(0, ring - BAND); + const innerSq = inner * inner; + ctx.clearRect(0, 0, W, H); ctx.fillStyle = color; - for (let gy = 0; gy < cells; gy++) { - for (let gx = 0; gx < cells; gx++) { + for (let gy = 0; gy < rows; gy++) { + const py = gy * CELL + CELL / 2; + const dy = py - y; + for (let gx = 0; gx < cols; gx++) { const px = gx * CELL + CELL / 2; - const py = gy * CELL + CELL / 2; - const dist = Math.hypot(px - center, py - center); - const band = 1 - Math.abs(dist - ring) / 34; // bright at the expanding front - if (band <= 0) continue; - const a = band * (0.35 + 0.65 * dither(gx, gy)) * (1 - t * 0.25); - if (a <= 0) continue; - ctx.globalAlpha = a > 1 ? 1 : a; + const dx = px - x; + const distSq = dx * dx + dy * dy; + // Cheap annulus reject before the sqrt: skip everything not on the front. + if (distSq > ringSq || distSq < innerSq) continue; + const dist = Math.sqrt(distSq); + const band = 1 - (ring - dist) / BAND; // brightest at the leading edge + const distFrac = dist / maxDist; // 0 at origin, 1 at far corner + const d = dither(gx, gy); + // Sparser the further out: distant cells need a high dither value to + // appear at all, so the wave frays into scattered pixels near the edges. + if (d < distFrac * 0.85) continue; + const a = band * (1 - distFrac * 0.6) * (1 - t * 0.2) * (0.4 + 0.6 * d) * ALPHA_CAP; + if (a <= 0.02) continue; + ctx.globalAlpha = a > ALPHA_CAP ? ALPHA_CAP : a; ctx.fillRect(gx * CELL, gy * CELL, CELL - 1, CELL - 1); } } @@ -95,16 +113,15 @@ const ImportDigest = forwardRef(({ color = '#c return ( ); diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index 33a56294..34ca3e79 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -20,7 +20,7 @@ import { ImportPreflight } from './shareTypes'; export const IMPORT_OPEN_EVENT = 'openswarm:import-open'; const ACCEPT = '.swarm,.md,.zip'; -const DIGEST_MS = 700; +const DIGEST_MS = 820; // keep in step with ImportDigest's wave so the blast reads fully const DEST: Record string | null> = { app: (id) => `/apps/${id}`, From 61c0576cf4dffc5b5be60510ed1a2712eafb8f93 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:26:13 -0700 Subject: [PATCH 024/174] [eric] dashboard: gate view-card orphan-prune on a fresh per-open outputs fetch so import no longer wipes the app card --- .../hooks/lifecycle/useDashboardLifecycle.ts | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index df6d4237..6f20c30b 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, type MutableRefObject } from 'react'; +import { useEffect, useRef, useState, type MutableRefObject } from 'react'; import { report } from '@/shared/serviceClient'; import { store } from '@/shared/state/store'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -58,6 +58,11 @@ export function useDashboardLifecycle({ restoredExpandedRef, }: UseDashboardLifecycleArgs) { const dispatch = useAppDispatch(); + // True once THIS dashboard open has refetched outputs. The orphan-prune below + // keys off this, not the sticky global outputsLoaded, so it never wipes a + // just-imported app card by judging it against a stale (previous-dashboard) + // apps list before the fresh fetch lands. + const [outputsRefetched, setOutputsRefetched] = useState(false); const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl); const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId); @@ -79,6 +84,7 @@ export function useDashboardLifecycle({ if (!dashboardId) return; hasFittedRef.current = false; restoredExpandedRef.current = false; + setOutputsRefetched(false); dispatch(resetLayout()); // CRITICAL path: these populate the cards the user expects to see // on first paint. Don't defer. @@ -97,17 +103,18 @@ export function useDashboardLifecycle({ // ~100ms later costs nothing). Pushing these into the post-paint // window measurably improves LCP because the initial render // pipeline isn't competing with their thunks/network setup. + const loadDeferred = () => { + dispatch(fetchHistory({ dashboardId })); + // Mark outputs fresh only after a SUCCESSFUL fetch, so the prune below + // judges view cards against this dashboard's real apps, not a stale list. + dispatch(fetchOutputs()).then((res) => { + if (fetchOutputs.fulfilled.match(res)) setOutputsRefetched(true); + }); + dashboardWs.connect(); + }; const idleHandle = (typeof window !== 'undefined' && (window as any).requestIdleCallback) - ? (window as any).requestIdleCallback(() => { - dispatch(fetchHistory({ dashboardId })); - dispatch(fetchOutputs()); - dashboardWs.connect(); - }, { timeout: 2000 }) - : window.setTimeout(() => { - dispatch(fetchHistory({ dashboardId })); - dispatch(fetchOutputs()); - dashboardWs.connect(); - }, 200); + ? (window as any).requestIdleCallback(loadDeferred, { timeout: 2000 }) + : window.setTimeout(loadDeferred, 200); // Pre-warm Anthropic's prompt cache for sessions on this dashboard // ~250ms after mount (debounced; AbortController cancels on @@ -243,16 +250,18 @@ export function useDashboardLifecycle({ }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); // Prune orphan view cards whose underlying output was deleted (e.g. via - // the Views page). Without this, the layout entry persists in the - // minimap and contentBounds even though DashboardViewCard renders - // nothing. Gated on outputsLoaded so we don't wipe valid cards during - // the brief window between fetchLayout returning and outputs finishing. + // the Views page). Without this, the layout entry persists in the minimap + // and contentBounds even though DashboardViewCard renders nothing. Gated on + // outputsRefetched (THIS open's fresh fetch), NOT the sticky global + // outputsLoaded: on a freshly-imported dashboard the global flag is already + // true from a prior dashboard, so the old gate pruned the just-imported app + // card against a stale apps list and the debounced save persisted the wipe. useEffect(() => { - if (!layoutInitialized || !outputsLoaded) return; + if (!layoutInitialized || !outputsRefetched) return; for (const outputId of Object.keys(viewCards)) { if (!outputs[outputId]) dispatch(removeViewCard(outputId)); } - }, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]); + }, [layoutInitialized, outputsRefetched, viewCards, outputs, dispatch]); // On first load after outputs settle, snapshot every existing Output id as // "already accounted for." Any output that ARRIVES later (typically the From 69764565037464c76c0c09711db589f7c286ae78 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:31:51 -0700 Subject: [PATCH 025/174] [eric] share: keep the drop border inside the rounded window, soften the import modal's review note + roomier includes list --- .../app/components/share/ImportEntryPoint.tsx | 43 +++++++++++-------- .../src/app/components/share/ImportModal.tsx | 27 +++++++++--- .../src/app/components/share/IncludesList.tsx | 16 +++---- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index 34ca3e79..7cefbe97 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -161,25 +161,30 @@ const ImportEntryPoint: React.FC = () => { /> - - - - Drop to add to OpenSwarm - + + {/* Full-bleed dim, rounded to match the window so its corners don't + spill past the OS's rounded corners. */} + + {/* The dashed drop-zone sits a hair inside so every corner stays in + view inside the rounded window, instead of getting clipped. */} + + + + Drop to add to OpenSwarm + + = ({ preflight, open, committing, onConfirm, {preflight.review && preflight.review.findings.length > 0 && ( - - {preflight.review.findings.map((f, i) => ( - - {f} - - ))} + + + + {preflight.review.findings.map((f, i) => ( + + {f} + + ))} + )} {preflight.conflicts.length > 0 && ( diff --git a/frontend/src/app/components/share/IncludesList.tsx b/frontend/src/app/components/share/IncludesList.tsx index 2cd6f290..00057d7f 100644 --- a/frontend/src/app/components/share/IncludesList.tsx +++ b/frontend/src/app/components/share/IncludesList.tsx @@ -27,15 +27,15 @@ const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { detail, faded, }) => ( - + @@ -63,10 +63,10 @@ const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { @@ -74,7 +74,7 @@ const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { ))} {summary.requirements.length > 0 && ( - + {summary.requirements.map((r, i) => ( ))} From a99e31094037bb4f7f55c3d4f05f88d9948acd44 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:32:46 -0700 Subject: [PATCH 026/174] [eric] diagnostics: attach secret-scrubbed 9router stderr tail to model_error telemetry (was masked 'check stderr') --- backend/apps/agents/agent_manager.py | 9 +++-- backend/apps/agents/core/error_classify.py | 25 ++++++++++++ backend/tests/test_error_classify.py | 45 ++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_error_classify.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 46e744d6..af9c7c65 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -35,6 +35,7 @@ from backend.apps.agents.core.error_classify import ( _is_long_context_error, _is_transient_capacity_error, _is_unknown_model_error, + redact_for_telemetry, ) from backend.apps.agents.manager.session.session_store import ( _delete_session_file, @@ -3151,7 +3152,7 @@ class AgentManager: "framework_overhead_tokens": session.framework_overhead_tokens, "active_mcps_count": len(session.active_mcps), "messages_count": len(session.messages), - "error_preview": (str(e) or "")[:500], + "error_preview": redact_for_telemetry(str(e), limit=500), }) except Exception: logger.debug("submit_diagnostic for context_overflow failed", exc_info=True) @@ -3256,7 +3257,8 @@ class AgentManager: "model": session.model, "provider": session.provider, "connection_mode": getattr(load_settings(), "connection_mode", "own_key"), - "error_preview": (str(e) or "")[:400], + "error_preview": redact_for_telemetry(str(e), limit=400), + "stderr_tail": redact_for_telemetry(_stderr_tail), }) except Exception: logger.debug("submit_diagnostic model_error failed", exc_info=True) @@ -3276,7 +3278,8 @@ class AgentManager: "model": session.model, "provider": session.provider, "connection_mode": getattr(load_settings(), "connection_mode", "own_key"), - "error_preview": (str(e) or "")[:400], + "error_preview": redact_for_telemetry(str(e), limit=400), + "stderr_tail": redact_for_telemetry(_stderr_tail), }) except Exception: logger.debug("submit_diagnostic model_error failed", exc_info=True) diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index 3a5d4aea..d7b1fb7b 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -1,5 +1,30 @@ import re +# Secret shapes that must never ride along when we ship a stderr tail or an +# error string to telemetry. own_key mode means the subprocess stderr can echo +# the user's OWN provider key, so this scrub is the wall between a diagnostic +# and a key leak; over-redacting is fine, leaking is not. +_TELEMETRY_SECRET_PATTERNS = ( + re.compile(r"sk-ant-[A-Za-z0-9_\-]{12,}"), + re.compile(r"sk-[A-Za-z0-9_\-]{16,}"), + re.compile(r"AIza[A-Za-z0-9_\-]{20,}"), + re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), + re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{12,}"), + re.compile(r"(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password|authorization)\b[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9._\-]{6,}"), +) + + +def redact_for_telemetry(text: str, *, limit: int = 2000) -> str: + """Scrub secret-shaped substrings, then keep the tail (where the real error + lands), bounded so a runaway log can't bloat the payload. Every raw + error/stderr string goes through here before it leaves the machine.""" + if not text: + return "" + for pat in _TELEMETRY_SECRET_PATTERNS: + text = pat.sub("[redacted]", text) + return text[-limit:] + + # Patterns that indicate an upstream transient problem (overload / rate limit / # infra blip), safe to silently retry with backoff. Checked against the # stringified exception from claude_agent_sdk / Claude CLI. diff --git a/backend/tests/test_error_classify.py b/backend/tests/test_error_classify.py new file mode 100644 index 00000000..191edead --- /dev/null +++ b/backend/tests/test_error_classify.py @@ -0,0 +1,45 @@ +"""redact_for_telemetry is the wall between a model_error diagnostic and a key +leak: in own_key mode the subprocess stderr we now attach can echo the user's +provider key, so these tests pin that no secret shape survives while the actual +error text (the whole point of capturing stderr) does. + +The secret-shaped inputs are built by concatenation on purpose: no contiguous +key-shaped literal lands in this source file (so it never trips gitleaks or +alarms a reader), yet the runtime values are still key-shaped enough to exercise +the scrub. None of these are real keys; they unlock nothing.""" +from backend.apps.agents.core.error_classify import redact_for_telemetry + + +def test_redacts_provider_key_shapes_keeps_context(): + anthropic = "sk-" + "ant-" + "A" * 28 + openai = "sk-" + "B" * 24 + google = "AIza" + "C" * 30 + github = "ghp" + "_" + "D" * 24 + s = f"9router: invalid x-api-key {anthropic} {openai} {google} {github}" + out = redact_for_telemetry(s) + for secret in (anthropic, openai, google, github): + assert secret not in out + assert "[redacted]" in out + # The diagnostic signal survives, that's the reason we capture stderr at all. + assert "9router: invalid x-api-key" in out + + +def test_redacts_bearer_and_key_value(): + bearer_token = "E" * 24 + kv_value = "F" * 16 + s = "Authorization: " + "Bearer " + bearer_token + "\n" + "api_key=" + kv_value + out = redact_for_telemetry(s) + assert bearer_token not in out + assert kv_value not in out + + +def test_keeps_tail_and_bounds_length(): + # The real error lands at the end of the stderr stream, so we keep the tail. + s = "old noise\n" * 500 + "Command failed: ENOENT spawn 9router" + out = redact_for_telemetry(s, limit=120) + assert len(out) <= 120 + assert "Command failed: ENOENT spawn 9router" in out + + +def test_empty_is_safe(): + assert redact_for_telemetry("") == "" From 3998a8d6d9a75d0623b5f83485ae88cf49ac1ff1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:44:15 -0700 Subject: [PATCH 027/174] [eric] sessions: get_all_sessions promotes only disk sessions the layout still cards, so deleted chats stop resurrecting on reopen --- backend/apps/agents/agent_manager.py | 31 +++++++++++++++++++------- backend/tests/test_swarm_bundle.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index af9c7c65..74c8c3c2 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -4639,17 +4639,20 @@ class AgentManager: def get_all_sessions(self, dashboard_id: str | None = None) -> list[AgentSession]: if not dashboard_id: return list(self.sessions.values()) - # Memory first, then promote any on-disk sessions for this dashboard - # that aren't loaded yet. Imported sessions (and ones not resumed since - # a restart) live on disk but not in memory, so without the disk pass - # their cards render blank, the frontend's AgentCard returns null when - # a card's session is missing from the agents slice. Promoting into - # self.sessions bounds the disk read to once per session per run, like - # resume_session. Mirrors get_browser_agent_children's memory+disk walk. + # Memory first, then promote on-disk sessions for this dashboard, but + # ONLY ones the dashboard's layout still has a card for. A session keeps + # its dashboard_id when its card is deleted, so promoting by tag alone + # resurrected deleted chats on every reopen; the layout's cards are the + # real source of truth for what's on the board. Imported sessions ARE in + # the layout, so they still surface, and this bounds the disk read to + # once per session per run, like resume_session. result = [s for s in self.sessions.values() if s.dashboard_id == dashboard_id] seen = {s.id for s in result} + card_ids = self._dashboard_card_ids(dashboard_id) for sid, data in _load_all_session_data(): - if sid in seen or data.get("dashboard_id") != dashboard_id: + if sid in seen or sid not in card_ids: + continue + if data.get("dashboard_id") != dashboard_id: continue try: sess = AgentSession(**data) @@ -4661,6 +4664,18 @@ class AgentManager: result.append(sess) return result + def _dashboard_card_ids(self, dashboard_id: str) -> set[str]: + """Session ids the dashboard's layout currently has agent cards for. + Read straight off disk (no dashboards-module import, avoids a cycle).""" + try: + import os + import backend.config.paths as _paths + from backend.config.json_store import read_json_or_none + d = read_json_or_none(os.path.join(_paths.DASHBOARDS_DIR, f"{dashboard_id}.json")) or {} + return set((d.get("layout", {}).get("cards") or {}).keys()) + except Exception: + return set() + def get_session(self, session_id: str) -> Optional[AgentSession]: return self.sessions.get(session_id) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index fe23d60a..0678bda6 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -323,6 +323,39 @@ def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, mo assert sum(len(s.messages) for s in found) == 2, "and with their transcripts" +def test_get_all_sessions_does_not_resurrect_deleted_cards(tmp_path, monkeypatch): + # Deleting a card removes it from the layout but the session keeps its + # dashboard_id on disk. get_all_sessions must surface only sessions the + # layout still has a card for, or deleted chats come back on every reopen. + from backend.apps.agents import agent_manager as am + import backend.config.paths as paths + sdir = tmp_path / "sessions" + ddir = tmp_path / "dashboards" + sdir.mkdir() + ddir.mkdir() + monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir)) + monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir)) + monkeypatch.setattr(am.agent_manager, "sessions", {}) + + did = "d1" + + def sess(sid): + return { + "id": sid, "name": sid, "status": "completed", "model": "sonnet", + "mode": "agent", "messages": [], "branches": {}, "active_branch_id": "main", + "dashboard_id": did, + } + + (sdir / "kept.json").write_text(json.dumps(sess("kept"))) + (sdir / "deleted.json").write_text(json.dumps(sess("deleted"))) # still tagged, card gone + # The layout has a card only for "kept" (the user deleted "deleted"'s card). + (ddir / f"{did}.json").write_text(json.dumps({"id": did, "layout": {"cards": {"kept": {"session_id": "kept"}}}})) + + ids = {s.id for s in am.agent_manager.get_all_sessions(dashboard_id=did)} + assert "kept" in ids, "a session the layout still has a card for must surface" + assert "deleted" not in ids, "a session whose card was deleted must NOT resurrect" + + def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): from backend.apps.swarm.entities.dashboards import DashboardExportable from backend.apps.swarm.models import EntityType From cd4ad8ca2e94fc5021921ee9d089b02b5715eb26 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:52:03 -0700 Subject: [PATCH 028/174] [eric] share/apps: collapse import contents to a one-line count (expandable), calmer app-card boot state with an honest first-run hint --- .../src/app/components/share/IncludesList.tsx | 64 +++++++++++++++++-- .../Dashboard/cards/DashboardViewCard.tsx | 57 +++++++++++------ 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/components/share/IncludesList.tsx b/frontend/src/app/components/share/IncludesList.tsx index 00057d7f..4494c16c 100644 --- a/frontend/src/app/components/share/IncludesList.tsx +++ b/frontend/src/app/components/share/IncludesList.tsx @@ -1,9 +1,13 @@ // The "what's inside this bundle" panel, shared by the Share and Import modals: // the root entity, the dependencies pulled in with it, and any environment -// requirements (an Action the importer must enable themselves). -import React from 'react'; +// requirements (an Action the importer must enable themselves). Long bundles +// (a dashboard pulls in every agent) read as a wall, so the contents collapse +// to a one-line count by default and expand on demand. Requirements always +// show, they're the part the importer has to act on. +import React, { useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -18,8 +22,23 @@ const KIND_LABEL: Record = { session: 'Agent', }; +const pluralize = (label: string, n: number): string => (n === 1 ? label : `${label}s`); + const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { const c = useClaudeTokens(); + const [expanded, setExpanded] = useState(false); + const includes = summary.includes; + + // One quiet line: "9 agents ยท 1 app", in the bundle's own type order. + const order: string[] = []; + const byType = new Map(); + for (const it of includes) { + if (!byType.has(it.type)) order.push(it.type); + byType.set(it.type, (byType.get(it.type) || 0) + 1); + } + const countLine = order + .map((t) => `${byType.get(t)} ${pluralize((KIND_LABEL[t] || t).toLowerCase(), byType.get(t) || 0)}`) + .join(' ยท '); const Row: React.FC<{ tag: string; name: string; detail?: string; faded?: boolean }> = ({ tag, @@ -27,7 +46,7 @@ const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { detail, faded, }) => ( - + = ({ summary }) => { }} > - {summary.includes.map((it, i) => ( - - ))} + + {includes.length > 0 && !expanded && ( + setExpanded(true)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 1, + py: 0.55, + cursor: 'pointer', + '&:hover .show-toggle': { color: c.accent.primary }, + }} + > + {countLine} + + Show + + + + )} + + {includes.length > 0 && expanded && ( + <> + {includes.map((it, i) => ( + + ))} + setExpanded(false)} + sx={{ py: 0.4, cursor: 'pointer', color: c.text.tertiary, '&:hover': { color: c.accent.primary } }} + > + Hide + + + )} + {summary.requirements.length > 0 && ( {summary.requirements.map((r, i) => ( diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 7abc7504..adc22f07 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -67,6 +67,44 @@ interface Props { onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void; } +// The app card's loading state while its runtime spins up. One soft pulse, calm +// copy, and an honest hint only after 9s, a freshly-imported app installs its +// deps on first open, which is the slow case worth explaining instead of leaving +// the user staring at a dead screen. +const BootingBody: React.FC = () => { + const c = useClaudeTokens(); + const [slow, setSlow] = useState(false); + useEffect(() => { + const t = setTimeout(() => setSlow(true), 9000); + return () => clearTimeout(t); + }, []); + return ( + + + Starting preview + + + First run sets the app up, this can take a moment. + + + + ); +}; + const DashboardViewCard: React.FC = ({ output, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false, isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, @@ -681,24 +719,7 @@ const DashboardOutputPreview: React.FC<{ } if (isBooting) { - return ( - - Starting previewโ€ฆ - - ); + return ; } return ( From 9d1ff22a418dd2f7f6378d9ef802c7bb240bd4e9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 04:55:14 -0700 Subject: [PATCH 029/174] [eric] 9router: verify-at-boot, report why a start failed (no silent DEVNULL) + no dev-npm fallback in packaged --- backend/apps/nine_router/process.py | 138 ++++++++++++++++++---------- 1 file changed, 89 insertions(+), 49 deletions(-) diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index eee3e4a1..b34a51e0 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -17,6 +17,7 @@ import os import secrets import shutil import subprocess +import tempfile import time from typing import Any @@ -297,6 +298,41 @@ def _ensure_router_cached() -> str | None: return server_js if os.path.exists(server_js) else None +def _read_capture_tail(path: str, limit: int = 6000) -> str: + """Tail of the 9Router start-capture file, where the real spawn error lands. + Best-effort; empty string on any hiccup so telemetry never breaks boot.""" + try: + with open(path, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - limit)) + return f.read().decode("utf-8", "replace") + except OSError: + return "" + + +def _report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> None: + """9Router didn't come up. Log it and ship a scrubbed diagnostic so a user's + 'every model exits 1' is finally explained from our side instead of a silent + warning. The stderr tail can echo an own_key, so it rides the same scrub as + every other telemetry string. Never raises.""" + logger.warning("9Router start failed (%s)", reason) + try: + from backend.apps.agents.core.error_classify import redact_for_telemetry + from backend.apps.service.client import submit_diagnostic + payload: dict[str, Any] = { + "kind": "9router_start_failed", + "reason": reason, + "packaged": os.environ.get("OPENSWARM_PACKAGED") == "1", + **fields, + } + if detail: + payload["stderr_tail"] = redact_for_telemetry(detail) + submit_diagnostic(payload) + except Exception: + logger.debug("9router start-failure diagnostic submit failed", exc_info=True) + + async def ensure_running(): """Start 9Router if not already running.""" global _process @@ -326,99 +362,103 @@ async def ensure_running(): logger.info("9Router already running on port %d", NINE_ROUTER_PORT) return _9router_dir = _find_9router_dir() + _patch = _gpt5_patch_path() - if _is_packaged and _9router_dir: - # Packaged mode; run the pre-built standalone server staged at - # /router/server.js by scripts/fetch-router.sh at build time. + if _is_packaged: + # Packaged: run the pre-built standalone server staged at + # /router/server.js by fetch-router at build time. We do NOT + # fall back to the dev npm path here, a user machine has no npm, so that + # only ever fails silently; every miss is reported instead. + if not _9router_dir: + _report_start_failure("router_not_bundled") + return standalone_server = os.path.join(_9router_dir, "server.js") if not os.path.exists(standalone_server): standalone_server = os.path.join(_9router_dir, ".next", "standalone", "server.js") if not os.path.exists(standalone_server): - logger.warning("9Router standalone build not found in %s", _9router_dir) + _report_start_failure("server_missing", router_dir_found=True) return - node = _find_node() if not node: - logger.warning("Node.js not found; cannot start 9Router in packaged mode.") + _report_start_failure("node_not_found", router_dir_found=True, server_found=True) return - logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT) - cmd = [node] - _patch = _gpt5_patch_path() - if _patch: - cmd += ["--require", _patch] - cmd.append(standalone_server) + cmd = [node] + (["--require", _patch] if _patch else []) + [standalone_server] cwd = os.path.dirname(standalone_server) env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} if node == os.environ.get("OPENSWARM_ELECTRON_PATH"): env["ELECTRON_RUN_AS_NODE"] = "1" - else: - # Dev mode; install the pinned 9router npm package into a local - # cache the first time run.sh boots, then spawn `node app/server.js` - # directly on subsequent launches. Bypassing the package's cli.js - # avoids its menu-bar tray icon (which users confusingly quit, - # silently killing their subscription routing), its update-check - # spinner, and the interactive TUI. + # Dev: install the pinned npm package into a local cache once, then spawn + # `node app/server.js` directly (bypasses the package cli.js tray icon + # users confusingly quit, its update-check spinner, and the TUI). cached_server = _ensure_router_cached() if not cached_server: return - node = _find_node() if not node: logger.warning("Node.js not found; cannot start 9Router in dev mode.") return - logger.info( "Starting 9Router (dev cache, 9router@%s) on port %d...", NINE_ROUTER_NPM_VERSION, NINE_ROUTER_PORT, ) - cmd = [node] - _patch = _gpt5_patch_path() - if _patch: - cmd += ["--require", _patch] - cmd.append(cached_server) + cmd = [node] + (["--require", _patch] if _patch else []) + [cached_server] cwd = os.path.dirname(cached_server) env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} - # By default, 9Router's stdout/stderr go to /dev/null (Next.js dev mode - # is extremely chatty and floods the openswarm console otherwise). When - # debugging is needed, set OPENSWARM_DEBUG_9ROUTER=1 in the environment - # before launching the backend; output will then be appended to - # backend/data/9router.log line-buffered, which can be `tail -f`'d. - if os.environ.get("OPENSWARM_DEBUG_9ROUTER"): + # Capture stdout+stderr so a failed start can tell us WHY (the old DEVNULL + # default made every "router never came up" a silent mystery, which is the + # whole reason #90 was un-diagnosable). Packaged prod (NODE_ENV=production + # standalone) is quiet, so one fixed temp file, truncated each start attempt, + # won't grow; dev keeps its chatty-Next.js DEVNULL unless debug is set. + _cap_path = os.path.join(tempfile.gettempdir(), "openswarm-9router-start.log") + _cap_file = None + if _is_packaged: + try: + _cap_file = open(_cap_path, "wb") + _stdout, _stderr = _cap_file, subprocess.STDOUT + except OSError: + _stdout, _stderr = subprocess.DEVNULL, subprocess.DEVNULL + elif os.environ.get("OPENSWARM_DEBUG_9ROUTER"): _log_path = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - "data", - "9router.log", + "data", "9router.log", ) os.makedirs(os.path.dirname(_log_path), exist_ok=True) - _stdout = open(_log_path, "a", buffering=1) # line-buffered - _stderr = subprocess.STDOUT + _stdout, _stderr = open(_log_path, "a", buffering=1), subprocess.STDOUT logger.info(f"9Router debug logging enabled โ†’ {_log_path}") else: - _stdout = subprocess.DEVNULL - _stderr = subprocess.DEVNULL + _stdout, _stderr = subprocess.DEVNULL, subprocess.DEVNULL try: - _process = subprocess.Popen( - cmd, - cwd=cwd, - stdout=_stdout, - stderr=_stderr, - env=env, - ) - + _process = subprocess.Popen(cmd, cwd=cwd, stdout=_stdout, stderr=_stderr, env=env) + if _cap_file is not None: + _cap_file.close() # the child holds its own fd; the parent copy isn't needed timeout = 20 if _is_packaged else 30 for _ in range(timeout * 2): await asyncio.sleep(0.5) if is_running(): logger.info("9Router started successfully") return - - logger.warning("9Router did not start within %ds", timeout) + # Verify-at-boot: it never answered. Report with the captured tail + the + # exit code (non-None = it crashed; None = wedged or just slow). + _report_start_failure( + "not_ready_in_time", + detail=_read_capture_tail(_cap_path) if _is_packaged else "", + returncode=_process.poll(), + timeout_s=timeout, + ) except Exception as e: - logger.warning(f"Failed to start 9Router: {e}") + if _cap_file is not None and not _cap_file.closed: + try: + _cap_file.close() + except OSError: + pass + _report_start_failure( + "spawn_exception", + detail=f"{e}\n{_read_capture_tail(_cap_path) if _is_packaged else ''}", + ) def stop(): From e53e11d28b8d1eb56e0c3bd78a540723aeea60ee Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 05:01:34 -0700 Subject: [PATCH 030/174] [eric] share: import-modal needs are compact icon chips now (explanation on hover) instead of three sentence rows --- .../src/app/components/share/IncludesList.tsx | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/share/IncludesList.tsx b/frontend/src/app/components/share/IncludesList.tsx index 4494c16c..1a9cdb61 100644 --- a/frontend/src/app/components/share/IncludesList.tsx +++ b/frontend/src/app/components/share/IncludesList.tsx @@ -7,12 +7,24 @@ import React, { useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; +import KeyOutlinedIcon from '@mui/icons-material/KeyOutlined'; +import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { BundleSummary } from './shareTypes'; +// One glyph per requirement kind, so a row of needs reads as icons + names +// instead of a stack of explanatory sentences. The sentence moves to a hover. +const REQ_ICON: Record = { + mcp_action: , + api_key: , + builtin_mode: , +}; + const KIND_LABEL: Record = { skill: 'Skill', app: 'App', @@ -127,9 +139,33 @@ const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { {summary.requirements.length > 0 && ( - {summary.requirements.map((r, i) => ( - - ))} + + Needs + + + {summary.requirements.map((r, i) => ( + + + {REQ_ICON[r.kind] || } + + {r.label} + + + + ))} + )} From 374ded021441c504c93308087264a187dd83ae28 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 05:08:09 -0700 Subject: [PATCH 031/174] [eric] share: minimal import modal, name lives in the title, one type+counts line, inline need chips, single-line trust note --- backend/apps/swarm/review.py | 2 +- .../src/app/components/share/ImportModal.tsx | 25 +-- .../src/app/components/share/IncludesList.tsx | 183 ++++++------------ 3 files changed, 68 insertions(+), 142 deletions(-) diff --git a/backend/apps/swarm/review.py b/backend/apps/swarm/review.py index fb8f0d00..b94a6e99 100644 --- a/backend/apps/swarm/review.py +++ b/backend/apps/swarm/review.py @@ -30,5 +30,5 @@ def scan_app_files(files: dict[str, bytes]) -> ReviewSummary: verdict = "warn" if findings else "clean" if runnable: verdict = "warn" - findings.insert(0, "This app runs code on your computer when you open it. Only import apps you trust.") + findings.insert(0, "This app runs code on your computer. Only import apps you trust.") return ReviewSummary(verdict=verdict, findings=findings, scanned_files=scanned) diff --git a/frontend/src/app/components/share/ImportModal.tsx b/frontend/src/app/components/share/ImportModal.tsx index 7025a5b8..08d6c3d4 100644 --- a/frontend/src/app/components/share/ImportModal.tsx +++ b/frontend/src/app/components/share/ImportModal.tsx @@ -56,26 +56,11 @@ const ImportModal: React.FC = ({ preflight, open, committing, onConfirm, {preflight.review && preflight.review.findings.length > 0 && ( - - - - {preflight.review.findings.map((f, i) => ( - - {f} - - ))} - + + + + {preflight.review.findings.join(' ')} + )} {preflight.conflicts.length > 0 && ( diff --git a/frontend/src/app/components/share/IncludesList.tsx b/frontend/src/app/components/share/IncludesList.tsx index 1a9cdb61..a1d13824 100644 --- a/frontend/src/app/components/share/IncludesList.tsx +++ b/frontend/src/app/components/share/IncludesList.tsx @@ -1,9 +1,8 @@ -// The "what's inside this bundle" panel, shared by the Share and Import modals: -// the root entity, the dependencies pulled in with it, and any environment -// requirements (an Action the importer must enable themselves). Long bundles -// (a dashboard pulls in every agent) read as a wall, so the contents collapse -// to a one-line count by default and expand on demand. Requirements always -// show, they're the part the importer has to act on. +// The "what's inside this bundle" panel, shared by the Share and Import modals. +// Kept deliberately spare: the bundle's name already lives in the modal title, so +// here it's just one line of type + counts, the requirements as small icon chips, +// and an optional expand for the full contents. No boxes, the modal's whitespace +// does the grouping. import React, { useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; @@ -17,14 +16,6 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { BundleSummary } from './shareTypes'; -// One glyph per requirement kind, so a row of needs reads as icons + names -// instead of a stack of explanatory sentences. The sentence moves to a hover. -const REQ_ICON: Record = { - mcp_action: , - api_key: , - builtin_mode: , -}; - const KIND_LABEL: Record = { skill: 'Skill', app: 'App', @@ -34,6 +25,13 @@ const KIND_LABEL: Record = { session: 'Agent', }; +// One glyph per requirement kind, so needs read as icons + names, not sentences. +const REQ_ICON: Record = { + mcp_action: , + api_key: , + builtin_mode: , +}; + const pluralize = (label: string, n: number): string => (n === 1 ? label : `${label}s`); const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { @@ -41,7 +39,7 @@ const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { const [expanded, setExpanded] = useState(false); const includes = summary.includes; - // One quiet line: "9 agents ยท 1 app", in the bundle's own type order. + // "9 agents ยท 1 app", in the bundle's own type order. const order: string[] = []; const byType = new Map(); for (const it of includes) { @@ -51,121 +49,64 @@ const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { const countLine = order .map((t) => `${byType.get(t)} ${pluralize((KIND_LABEL[t] || t).toLowerCase(), byType.get(t) || 0)}`) .join(' ยท '); - - const Row: React.FC<{ tag: string; name: string; detail?: string; faded?: boolean }> = ({ - tag, - name, - detail, - faded, - }) => ( - - - {tag} - - - {name} - - {detail && ( - {detail} - )} - - ); + const rootLabel = KIND_LABEL[summary.root.type] || summary.root.type; return ( - - - - {includes.length > 0 && !expanded && ( - setExpanded(true)} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 1, - py: 0.55, - cursor: 'pointer', - '&:hover .show-toggle': { color: c.accent.primary }, - }} - > - {countLine} - - Show - + + {/* Lead: the bundle's type + a one-line count. The name is in the title. */} + + + {rootLabel} + {countLine && ` ยท ${countLine}`} + + {includes.length > 0 && ( + setExpanded((v) => !v)} + sx={{ display: 'flex', alignItems: 'center', gap: 0.25, color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.accent.primary } }} + > + {expanded ? 'Hide' : 'Show'} + + )} + + + {expanded && includes.length > 0 && ( + + {includes.map((it, i) => ( + + + {KIND_LABEL[it.type] || it.type} + + + {it.name} + + + ))} )} - {includes.length > 0 && expanded && ( - <> - {includes.map((it, i) => ( - - ))} - setExpanded(false)} - sx={{ py: 0.4, cursor: 'pointer', color: c.text.tertiary, '&:hover': { color: c.accent.primary } }} - > - Hide - - - )} - {summary.requirements.length > 0 && ( - - + + Needs - - {summary.requirements.map((r, i) => ( - - - {REQ_ICON[r.kind] || } - - {r.label} - - - - ))} - + {summary.requirements.map((r, i) => ( + + + {REQ_ICON[r.kind] || } + + {r.label} + + + + ))} )} From e1ac292eeb5d2d554d8259bdf6b2697d5b44d69d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 08:03:34 -0700 Subject: [PATCH 032/174] [eric] mouseclamp: also clamp off-window releases whose event has no window (key-window fallback + screen->window map), closes the gap that still crashed RootView::UpdateCursor on 1.2.84 --- electron/native/mouseclamp/mouseclamp.mm | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/electron/native/mouseclamp/mouseclamp.mm b/electron/native/mouseclamp/mouseclamp.mm index ff1b5f9d..1bc7869f 100644 --- a/electron/native/mouseclamp/mouseclamp.mm +++ b/electron/native/mouseclamp/mouseclamp.mm @@ -24,9 +24,20 @@ static NSEvent *ClampOffWindowRelease(NSEvent *event) { return event; } NSWindow *win = [event window]; + NSPoint p; + if (win && [win contentView]) { + // Normal case: the captured window rode along on the event. + p = [event locationInWindow]; + } else { + // The original gap: a release off the source window (easy with a second + // display) can arrive with no window attached, so the old code fail-opened + // here and the crash slipped through. Fall back to the key/main window and + // map the screen-space location into it so we can still snap it. + win = [NSApp keyWindow] ?: [NSApp mainWindow]; + if (!win || ![win contentView]) return event; + p = [win convertPointFromScreen:[event locationInWindow]]; + } NSView *content = [win contentView]; - if (!content) return event; - NSPoint p = [event locationInWindow]; NSSize ws = [win frame].size; NSRect cb = [content frame]; // all the misfire-prone arithmetic lives in clamp_decision() so the property @@ -40,7 +51,7 @@ static NSEvent *ClampOffWindowRelease(NSEvent *event) { location:NSMakePoint(d.x, d.y) modifierFlags:[event modifierFlags] timestamp:[event timestamp] - windowNumber:[event windowNumber] + windowNumber:[win windowNumber] context:nil eventNumber:[event eventNumber] clickCount:[event clickCount] From fc1c3eeceacea9095c46d56579a9d6c885ef79c4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 16:52:11 -0700 Subject: [PATCH 033/174] [eric] release: bump to 1.2.85 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index af023c3f..b0d2ee65 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.2.84", + "version": "1.2.85", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From ec99d24b8e7c5e482f15700b697436bbb2d04dcd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 16:59:39 -0700 Subject: [PATCH 034/174] [eric] release: bump to 1.3.85 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index b0d2ee65..6ea396a5 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.2.85", + "version": "1.3.85", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From 6d1c66760eb8921c5fed9f8a1f8869e07535169f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 17:34:45 -0700 Subject: [PATCH 035/174] [eric] notarize: use openswarm-notary keychain profile, disable env-cred auto-notarize --- electron/package.json | 1 + electron/scripts/notarize.js | 34 +++++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/electron/package.json b/electron/package.json index 6ea396a5..86330afc 100644 --- a/electron/package.json +++ b/electron/package.json @@ -50,6 +50,7 @@ ], "category": "public.app-category.developer-tools", "hardenedRuntime": true, + "notarize": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "extraResources": [ diff --git a/electron/scripts/notarize.js b/electron/scripts/notarize.js index d0a6e7c2..4efe6afa 100644 --- a/electron/scripts/notarize.js +++ b/electron/scripts/notarize.js @@ -12,8 +12,15 @@ exports.default = async function notarizing(context) { return; } - if (!process.env.APPLE_ID || !process.env.APPLE_TEAM_ID) { - console.log('Skipping notarization (APPLE_ID or APPLE_TEAM_ID not set)'); + // Prefer a stored notarytool keychain profile: an app-specific password in env + // silently 401s the day Apple rotates it, taking a release down with it; the + // keychain profile is durable and is the canonical local-publish credential. + const keychainProfile = process.env.APPLE_KEYCHAIN_PROFILE; + const hasEnvCreds = + process.env.APPLE_ID && process.env.APPLE_APP_SPECIFIC_PASSWORD && process.env.APPLE_TEAM_ID; + + if (!keychainProfile && !hasEnvCreds) { + console.log('Skipping notarization (no APPLE_KEYCHAIN_PROFILE and no APPLE_ID/password/team)'); return; } @@ -22,15 +29,20 @@ exports.default = async function notarizing(context) { const appName = context.packager.appInfo.productFilename; const appPath = `${appOutDir}/${appName}.app`; - console.log(`Notarizing ${appPath}...`); - - await notarize({ - appBundleId: 'com.clusterlabs.openswarm', - appPath, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, - teamId: process.env.APPLE_TEAM_ID, - }); + if (keychainProfile) { + console.log(`Notarizing ${appPath} via keychain profile "${keychainProfile}"...`); + await notarize({ tool: 'notarytool', appPath, keychainProfile }); + } else { + console.log(`Notarizing ${appPath} via Apple ID env credentials...`); + await notarize({ + tool: 'notarytool', + appBundleId: 'com.clusterlabs.openswarm', + appPath, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }); + } console.log('Notarization complete.'); }; From b0fb8841b0777a1da7158e6330db892e08a94750 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 17:35:53 -0700 Subject: [PATCH 036/174] Revert "[eric] notarize: use openswarm-notary keychain profile, disable env-cred auto-notarize" This reverts commit 6d1c66760eb8921c5fed9f8a1f8869e07535169f. --- electron/package.json | 1 - electron/scripts/notarize.js | 34 +++++++++++----------------------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/electron/package.json b/electron/package.json index 86330afc..6ea396a5 100644 --- a/electron/package.json +++ b/electron/package.json @@ -50,7 +50,6 @@ ], "category": "public.app-category.developer-tools", "hardenedRuntime": true, - "notarize": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "extraResources": [ diff --git a/electron/scripts/notarize.js b/electron/scripts/notarize.js index 4efe6afa..d0a6e7c2 100644 --- a/electron/scripts/notarize.js +++ b/electron/scripts/notarize.js @@ -12,15 +12,8 @@ exports.default = async function notarizing(context) { return; } - // Prefer a stored notarytool keychain profile: an app-specific password in env - // silently 401s the day Apple rotates it, taking a release down with it; the - // keychain profile is durable and is the canonical local-publish credential. - const keychainProfile = process.env.APPLE_KEYCHAIN_PROFILE; - const hasEnvCreds = - process.env.APPLE_ID && process.env.APPLE_APP_SPECIFIC_PASSWORD && process.env.APPLE_TEAM_ID; - - if (!keychainProfile && !hasEnvCreds) { - console.log('Skipping notarization (no APPLE_KEYCHAIN_PROFILE and no APPLE_ID/password/team)'); + if (!process.env.APPLE_ID || !process.env.APPLE_TEAM_ID) { + console.log('Skipping notarization (APPLE_ID or APPLE_TEAM_ID not set)'); return; } @@ -29,20 +22,15 @@ exports.default = async function notarizing(context) { const appName = context.packager.appInfo.productFilename; const appPath = `${appOutDir}/${appName}.app`; - if (keychainProfile) { - console.log(`Notarizing ${appPath} via keychain profile "${keychainProfile}"...`); - await notarize({ tool: 'notarytool', appPath, keychainProfile }); - } else { - console.log(`Notarizing ${appPath} via Apple ID env credentials...`); - await notarize({ - tool: 'notarytool', - appBundleId: 'com.clusterlabs.openswarm', - appPath, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, - teamId: process.env.APPLE_TEAM_ID, - }); - } + console.log(`Notarizing ${appPath}...`); + + await notarize({ + appBundleId: 'com.clusterlabs.openswarm', + appPath, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }); console.log('Notarization complete.'); }; From 7a8cbad025324803e814745754fe858bca4c822f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 17:34:45 -0700 Subject: [PATCH 037/174] [eric] notarize: use openswarm-notary keychain profile, disable env-cred auto-notarize --- electron/package.json | 1 + electron/scripts/notarize.js | 34 +++++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/electron/package.json b/electron/package.json index 6ea396a5..86330afc 100644 --- a/electron/package.json +++ b/electron/package.json @@ -50,6 +50,7 @@ ], "category": "public.app-category.developer-tools", "hardenedRuntime": true, + "notarize": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "extraResources": [ diff --git a/electron/scripts/notarize.js b/electron/scripts/notarize.js index d0a6e7c2..4efe6afa 100644 --- a/electron/scripts/notarize.js +++ b/electron/scripts/notarize.js @@ -12,8 +12,15 @@ exports.default = async function notarizing(context) { return; } - if (!process.env.APPLE_ID || !process.env.APPLE_TEAM_ID) { - console.log('Skipping notarization (APPLE_ID or APPLE_TEAM_ID not set)'); + // Prefer a stored notarytool keychain profile: an app-specific password in env + // silently 401s the day Apple rotates it, taking a release down with it; the + // keychain profile is durable and is the canonical local-publish credential. + const keychainProfile = process.env.APPLE_KEYCHAIN_PROFILE; + const hasEnvCreds = + process.env.APPLE_ID && process.env.APPLE_APP_SPECIFIC_PASSWORD && process.env.APPLE_TEAM_ID; + + if (!keychainProfile && !hasEnvCreds) { + console.log('Skipping notarization (no APPLE_KEYCHAIN_PROFILE and no APPLE_ID/password/team)'); return; } @@ -22,15 +29,20 @@ exports.default = async function notarizing(context) { const appName = context.packager.appInfo.productFilename; const appPath = `${appOutDir}/${appName}.app`; - console.log(`Notarizing ${appPath}...`); - - await notarize({ - appBundleId: 'com.clusterlabs.openswarm', - appPath, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, - teamId: process.env.APPLE_TEAM_ID, - }); + if (keychainProfile) { + console.log(`Notarizing ${appPath} via keychain profile "${keychainProfile}"...`); + await notarize({ tool: 'notarytool', appPath, keychainProfile }); + } else { + console.log(`Notarizing ${appPath} via Apple ID env credentials...`); + await notarize({ + tool: 'notarytool', + appBundleId: 'com.clusterlabs.openswarm', + appPath, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }); + } console.log('Notarization complete.'); }; From 24a767eec949370f9c8f7ddb9d2587f488fad643 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:58:39 -0700 Subject: [PATCH 038/174] [eric] perf: winv2 windows startup + app builder profiling, tracking doc, graphs - warm boot floor 8256ms -> 857ms; app builder download + create breakdown measured - harnesses, csv, dependency-free svg graphs, per-step savings table, #9 plan --- docs/perf/winv2/README.md | 173 +++++++++++++++++++++ docs/perf/winv2/appbuilder_breakdown.csv | 7 + docs/perf/winv2/appbuilder_breakdown.svg | 22 +++ docs/perf/winv2/baseline_phases.svg | 12 ++ docs/perf/winv2/baseline_startup.csv | 15 ++ docs/perf/winv2/baseline_startup.svg | 57 +++++++ docs/perf/winv2/boot_breakdown.csv | 5 + docs/perf/winv2/boot_breakdown.svg | 31 ++++ docs/perf/winv2/make_graphs.py | 190 +++++++++++++++++++++++ docs/perf/winv2/measure_appbuilder.py | 135 ++++++++++++++++ docs/perf/winv2/measure_vite.py | 70 +++++++++ docs/perf/winv2/profile_boot.py | 67 ++++++++ docs/perf/winv2/profile_startup.sh | 31 ++++ 13 files changed, 815 insertions(+) create mode 100644 docs/perf/winv2/README.md create mode 100644 docs/perf/winv2/appbuilder_breakdown.csv create mode 100644 docs/perf/winv2/appbuilder_breakdown.svg create mode 100644 docs/perf/winv2/baseline_phases.svg create mode 100644 docs/perf/winv2/baseline_startup.csv create mode 100644 docs/perf/winv2/baseline_startup.svg create mode 100644 docs/perf/winv2/boot_breakdown.csv create mode 100644 docs/perf/winv2/boot_breakdown.svg create mode 100644 docs/perf/winv2/make_graphs.py create mode 100644 docs/perf/winv2/measure_appbuilder.py create mode 100644 docs/perf/winv2/measure_vite.py create mode 100644 docs/perf/winv2/profile_boot.py create mode 100644 docs/perf/winv2/profile_startup.sh diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md new file mode 100644 index 00000000..d74e3f3f --- /dev/null +++ b/docs/perf/winv2/README.md @@ -0,0 +1,173 @@ +# winv2: Windows startup + App Builder speed and bug fixes + +Branch: `eric/winv2`. Goal: profile the real Windows experience first, find the +biggest bottleneck before changing anything, then fix the two reported bugs and +make startup + first-app download feel instant. All numbers below are measured +on the **real installed packaged app** (Squirrel install at +`AppData/Local/openswarm`, latest `app-1.2.82`), Windows 11, not dev mode. + +Notion tracking (Todos DB): +- [Perf] Windows startup + download speed: backend cold-start is the bottleneck +- [App Builder] Windows preview broken: no bundled bash/npm + missing node_modules archive +- [Bug] Skills list empty until reboots + onboarding "Install a skill" step times out +- [Reliability] Distributed-systems hardening (design) + +## How these numbers were measured + +Source of truth: the packaged app's own perf markers in +`AppData/Roaming/openswarm/data/backend.log` (`[perf] app-launch`, +`[perf] first-paint`, `[perf] backend-http-ready`, written by `electron/main.js`). +These are wall-clock ms from process start, i.e. exactly what the user feels. +Raw extract: `baseline_startup.csv`. Re-run with `profile_startup.sh`. + +Import cost measured with the bundled interpreter: +`python-env/python.exe -X importtime -c "import backend.main"`. + +## Baseline (BEFORE any change) + +### Startup, per launch (ms) + +| metric | warm (typical) | cold (first run after each update) | +| --- | --- | --- | +| app-launch (electron ready) | 107-400 | 107-563 | +| first-paint (renderer) | 338-1205 | ~1200 | +| **backend-http-ready** | **8700-10500** | **54600 / 81000 / 86300 / 133000 / 138300** | + +Electron shell paints in well under 1.5s every time. The Python backend is the +whole story: ~9-10s warm, and **54-138 seconds** on a cold/post-update launch. +First-agent-response figures in the log are dominated by user think-time and are +not treated as a startup metric. + +### Why the backend is slow (evidence) + +| factor | measurement | effect | +| --- | --- | --- | +| python-env file count | 13,554 files (4,510 .py/.pyd/.dll), 484 MB | Windows Defender real-time scan of every file on the first run after each update = the 1-2 minute cold spikes | +| app.asar size | 639 MB | cold disk read on first launch | +| backend.main import tree | ~2.2 s warm (`-X importtime`) | floor on warm boot, before interpreter init + lifespans | +| debugger project scan | runs at import (DEBUGLETON / build_structure) | extra warm boot time on the critical path | +| SubApp lifespans | entered sequentially in `config/Apps.py` before HTTP bind | serialized startup I/O | + +## Bottleneck ranking (before changes) + +1. **Python backend cold-start (dominant).** 9-10s warm, 54-138s cold. ~95% of + perceived startup. Cold case driven by Defender scanning 13.5k files + the + 639 MB asar; warm case by import tree + debugger scan + serial lifespans. +2. **App Builder first-app on Windows is fully broken** (Bug #2): no bundled + bash, bundled node has no npm, and the Windows build ships no node_modules + archive. Confirmed against the installed binary. Until fixed, "download time" + for an app is effectively infinite (it never succeeds on a clean machine). +3. **Skills registry network race** (Bug #1): empty catalog until reboot, breaks + the onboarding "Install a skill" step (15s selector timeout). + +## Plan (status tracked here + on Notion) + +- [~] Bug #2 App Builder: **junction/copy link fallback DONE + tested**; archive in Windows build + direct vite spawn (no bash) TODO +- [~] Bug #1 Skills: **bundled snapshot + disk cache + retry-until-success DONE + tested** (catalog never empty offline, onboarding pdf selector resolves); frontend loading-vs-empty retry TODO +- [ ] Perf: trim Defender surface, lazy imports, non-blocking lifespans, move debugger scan off boot, App Builder warm pool +- [ ] Re-measure, before/after tables + graphs + +## Progress log + +- 2026-06-16 baseline measured (this doc), graphs generated, Notion todos opened. +- 2026-06-16 Bug #1 backend: `skill_registry.py` now seeds from bundled `skills_snapshot.json` + on-disk last-good cache and retries until first success. Proven non-empty fully offline (17 skills, search+stats green); `pdf` skill present so onboarding `skill-item-pdf` resolves. Regression test `backend/tests/test_skill_registry_seed.py` (3 cases green). +- 2026-06-16 Bug #2 link: `_link_node_modules` now falls back symlink -> junction (`mklink /J`, no admin) -> copy, so node_modules links even on a locked-down Windows box. Tested with forced symlink failure. + +## Results (AFTER) + +### The warm-startup bottleneck was found and fixed + +Per-SubApp-lifespan profiling (`profile_boot.py`) showed the entire ~8s gap was +**one lifespan**: + +| boot phase | before | after | note | +| --- | --- | --- | --- | +| import backend.main | 798 ms | 764 ms | unchanged (debugger scan is only ~80 ms) | +| **service lifespan** | **7412 ms** | **84 ms** | was `await ensure_9router()` blocking the HTTP bind | +| other 15 lifespans | 45 ms | 9 ms | all trivial | +| **import + lifespans floor** | **8256 ms** | **857 ms** | ~7.4 s removed (~90%) | + +Fix: `service.py` now starts 9Router in the **background** instead of awaiting it +on the boot path. 9Router is only needed when the user sends an agent message, +and the dispatch path already calls `ensure_running()` (now lock-serialized in +`process.py` so the background start and a dispatch-time ensure can't +double-spawn). Net: warm backend-http-ready should drop from ~9-10 s to ~2-3 s, +comfortably under the 10 s goal. See `boot_breakdown.svg`. + +### Still open (cold start) + +The 54-138 s cold spikes are Windows Defender scanning the 13,554-file / 484 MB +python-env on the first run after each update, plus cold-reading the 639 MB +asar. That is a packaging change (fewer/larger files, trusted-location, or +zipped stdlib) and is higher-risk, tracked separately. The 9Router backgrounding +also helps cold (it no longer compounds the Defender wait). + +### App Builder first-app "download" + create path (measured) + +Per-phase, measured on this Windows box (`measure_appbuilder.py` + `measure_vite.py`), +isolated temp dirs, real warm caches. See `appbuilder_breakdown.svg`. + +| phase | time | when it's paid | +| --- | --- | --- | +| seed workspace + link node_modules | 67 ms | every app (instant; junction/symlink to warm cache) | +| download: archive extract (new build path) | 14.2 s | once per machine/template version (Defender-bound: 215 MB nm) | +| download: npm install (cold fallback) | 42.7 s | once, only if no archive ships | +| vite bind: cold vite cache | 6.7 s | first app ever (esbuild pre-bundle) | +| vite bind: warm shared cache | 0.7 s | every subsequent app | +| build-time: tar nm -> archive | 6.8 s | on CI, never on the user's machine | + +**User-facing scenarios (create app -> live preview):** + +| scenario | total | notes | +| --- | --- | --- | +| first app, clean Windows, BEFORE fix | never works | `[WinError 2]` / "backend exited with code 1" (no bash/npm/archive) | +| first app, AFTER fix (tar archive) | ~21 s one-time | extract 14.2 + seed 0.07 + vite cold 6.7; and it actually works | +| **first app, AFTER fix + #9 item 2 (pre-extracted)** | **~7 s one-time (projected)** | **junction 0.07 + vite cold 6.7; the 14.2 s extract is gone** | +| first app, if we shipped npm instead | ~49 s | 42.7 + 6.7; the archive saves ~28 s and needs no npm | +| every subsequent app | ~0.8 s | seed 0.07 + vite warm 0.7 (near-instant) | + +#9 item 2 (DONE): the Windows build now ships node_modules ALREADY EXTRACTED in +resources (digest-tagged); `_ensure_warm_cache` junctions a workspace straight at +it (`_bundled_extracted_modules`), so there is no tar-extract on first app -- the +14.2 s Defender-scanned write cost moves to install time, once. Verified by +`backend/tests/test_bundled_extracted_modules.py` (selection + Mac fallback) and +the build step `build-app-win.ps1` 4b now robocopies the tree into resources. + +Takeaways: the archive (Bug #2 fix) turns a broken/โˆž first-app into a working +~21s one-time, and ~0.8s for every app after. The remaining ~14s extract is the +SAME Defender-on-many-small-files cost as cold app-startup (Task #9) -- the one +lever that would shrink both. + +### Net time decreased per step (measured) + +| step | before | after | saved | +| --- | --- | --- | --- | +| backend boot: service lifespan | 7412 ms | 84 ms | -7328 ms (-99%) | +| backend boot: import + all lifespans floor | 8256 ms | 857 ms | -7399 ms (-90%) | +| backend-http-ready warm (end-to-end) | ~9-10 s | ~2-3 s (projected) | ~-7 s | +| App Builder dependency download | 42.7 s npm | 14.2 s archive | -28.5 s (-67%) | +| App Builder first app -> preview | broken/never | ~21 s working | inf -> 21 s | +| App Builder subsequent app -> preview | n/a | ~0.8 s | near-instant | +| skills catalog availability | empty until reboot(s) | instant (seeded) | bug eliminated | + +## #9 packaging approach: shrink the Defender file surface (build-gated) + +Defender real-time-scans every small file: python-env = 13,554 files; node_modules += ~tens of thousands; app.asar = 639 MB. It rescans python-env on the first launch +after each update (54-138 s cold spikes) and scans node_modules as it is written +(the 14.2 s extract). Fix family: fewer/larger files, scan-once-at-install instead +of per-launch / per-first-app. Each item is independent, reversible, and must be +validated on a real packaged EXE (Task #10). + +1. [DRAFTED, build-gated] Zip the Python stdlib -> python313.zip (medium risk). Draft: scripts/zip-python-stdlib.ps1 (dry-run by default; NOT wired into the release build yet). Measured on the real env: 910 stdlib .py/.pyc files (15.1 MB) collapse into one zip. CPython auto-adds /python313.zip to sys.path, so no python._pth is needed; site-packages + DLLs (native .pyd) stay loose; a keep-list keeps data-file stdlib dirs (lib2to3, idlelib, tkinter, ...) loose. Impact: ~7% of total python-env file count, but it collapses the stdlib import-time file-opens (the cold-launch Defender scan storm) into a single scanned file; bigger combined with #3. Validation (Task #10): -Apply on a copy, then import backend.main, importtime parity, boot the packaged backend, measure cold backend-http-ready vs baseline. Wire into build-app-win.ps1 behind an off-by-default -ZipStdlib switch only after it passes. +2. [DONE] Ship webapp_template node_modules PRE-EXTRACTED in resources + junction to it (kills the 14.2 s extract -> ~0 s). build-app-win.ps1 step 4b robocopies the tree into resources; runtime _bundled_extracted_modules()/_ensure_warm_cache() prefer it; tests in test_bundled_extracted_modules.py. Mac still ships the .tar.gz (unchanged). +3. Precompile + ship only .pyc (drop .py) for app + pure-python deps. Halves remaining loose-file count; low risk; stacks with #1. +4. Inventory + trim app.asar (639 MB): source maps, dev-only deps, duplicate bundles. Single file (not a count issue) but shrinks cold-read I/O. +5. Opt-in Defender exclusion for install/data dirs, documented, never silent (needs admin/UAC; security-sensitive). Settings toggle only; do not auto-apply. + +Recommended order: #2 (biggest UX win, lowest risk), then #1 (largest cold win, careful import testing), then #3/#4. Validation: re-run profile_startup.sh + a fresh-extract timing on the packaged EXE after each change, diff vs baseline_startup.csv. + +### Bug fixes (this branch) + +- Bug #1 skills: seed from bundled snapshot + disk cache + retry-until-success. Catalog never empty offline; 3 tests green; onboarding `skill-item-pdf` resolves. +- Bug #2 App Builder: (a) `_link_node_modules` symlink->junction->copy fallback (tested); (b) Windows-only direct `vite` spawn via bundled node so frontend-only apps need no bash (kills `[WinError 2]`); (c) `build-app-win.ps1` now pre-builds the node_modules archive natively. Verified end to end on Windows: build digest == runtime `_warm_cache_digest` (`37335fdd1f4d`); the archive (26 MB) extracts to a working node_modules containing `vite/bin/vite.js` and the Windows-native `@esbuild/win32-x64/esbuild.exe`. diff --git a/docs/perf/winv2/appbuilder_breakdown.csv b/docs/perf/winv2/appbuilder_breakdown.csv new file mode 100644 index 00000000..4d8a4d9b --- /dev/null +++ b/docs/perf/winv2/appbuilder_breakdown.csv @@ -0,0 +1,7 @@ +phase,ms,note +"seed workspace + link node_modules (per app)",67,"nm linked, instant" +"download: archive extract (new build path, one-time)",14204,"215MB nm, defender-bound" +"download: npm install (cold fallback, one-time)",42684,"ok" +"vite bind: cold vite cache (first app)",6714,"bound" +"vite bind: warm shared cache (subsequent)",672,"bound" +"build-time: tar node_modules to archive (CI, not user)",6809,"26MB archive" diff --git a/docs/perf/winv2/appbuilder_breakdown.svg b/docs/perf/winv2/appbuilder_breakdown.svg new file mode 100644 index 00000000..0289a865 --- /dev/null +++ b/docs/perf/winv2/appbuilder_breakdown.svg @@ -0,0 +1,22 @@ + +App Builder "create app -> live preview" breakdown (ms) +seed workspace + link node_modules (per app) + +67ms +download: archive extract (new build path, one-time) + +14.20s +download: npm install (cold fallback, one-time) + +42.68s +vite bind: cold vite cache (first app) + +6.71s +vite bind: warm shared cache (subsequent) + +672ms +build-time: tar node_modules to archive (CI, not user) + +6.81s +green = warm/per-app cost; red = cold one-time download (npm with no archive) + \ No newline at end of file diff --git a/docs/perf/winv2/baseline_phases.svg b/docs/perf/winv2/baseline_phases.svg new file mode 100644 index 00000000..fc9ce33d --- /dev/null +++ b/docs/perf/winv2/baseline_phases.svg @@ -0,0 +1,12 @@ + +where startup time goes (backend dwarfs the shell) +typical warm launch + + +backend 9.6s (shell 0.68s) +typical cold launch + + +backend 86.3s (shell 1.48s) +dark = electron shell (app-launch + first-paint); colored = python backend + \ No newline at end of file diff --git a/docs/perf/winv2/baseline_startup.csv b/docs/perf/winv2/baseline_startup.csv new file mode 100644 index 00000000..70a803d0 --- /dev/null +++ b/docs/perf/winv2/baseline_startup.csv @@ -0,0 +1,15 @@ +launch_ts,version,app_launch_ms,first_paint_ms,backend_http_ready_ms,class +2026-06-02T09:18:13Z,1.1.72,380,1097,54606,cold +2026-06-08T22:16:53Z,1.2.73,143,636,10463,warm +2026-06-08T23:03:47Z,1.2.73,317,625,10084,warm +2026-06-08T23:58:30Z,1.2.73,198,515,81041,cold +2026-06-09T03:13:05Z,1.2.73,147,559,10418,warm +2026-06-09T11:04:12Z,1.2.75,129,609,10041,warm +2026-06-09T11:53:18Z,1.2.75,108,338,8761,warm +2026-06-10T07:44:10Z,1.2.75,388,1100,86310,cold +2026-06-10T07:46:53Z,1.2.76,112,389,9342,warm +2026-06-10T23:32:57Z,1.2.76,563,1205,133070,cold +2026-06-10T23:35:13Z,1.2.77,114,391,9349,warm +2026-06-10T23:35:41Z,1.2.77,122,404,9303,warm +2026-06-14T00:07:35Z,1.2.77,159,1213,138335,cold +2026-06-14T00:09:58Z,1.2.82,107,702,9590,warm diff --git a/docs/perf/winv2/baseline_startup.svg b/docs/perf/winv2/baseline_startup.svg new file mode 100644 index 00000000..14f09006 --- /dev/null +++ b/docs/perf/winv2/baseline_startup.svg @@ -0,0 +1,57 @@ + +backend-http-ready per launch (ms) - lower is better + +0s + +35s + +69s + +104s + +138s + +55s +1.1.72 + +10s +1.2.73 + +10s +1.2.73 + +81s +1.2.73 + +10s +1.2.73 + +10s +1.2.75 + +9s +1.2.75 + +86s +1.2.75 + +9s +1.2.76 + +133s +1.2.76 + +9s +1.2.77 + +9s +1.2.77 + +138s +1.2.77 + +10s +1.2.82 +warm +cold (post-update) + \ No newline at end of file diff --git a/docs/perf/winv2/boot_breakdown.csv b/docs/perf/winv2/boot_breakdown.csv new file mode 100644 index 00000000..025ec1f6 --- /dev/null +++ b/docs/perf/winv2/boot_breakdown.csv @@ -0,0 +1,5 @@ +phase,before_ms,after_ms +import backend.main,798,764 +service lifespan (9router start),7412,84 +other 15 lifespans,45,9 +import + lifespans floor,8256,857 diff --git a/docs/perf/winv2/boot_breakdown.svg b/docs/perf/winv2/boot_breakdown.svg new file mode 100644 index 00000000..4956f784 --- /dev/null +++ b/docs/perf/winv2/boot_breakdown.svg @@ -0,0 +1,31 @@ + +warm boot breakdown: before vs after (ms) - the service lifespan was the bottleneck + +0.0s + +4.1s + +8.3s + +0.8s + +0.8s +import backend.main + +7.4s + +0.1s +service lifespan (9router start) + +0.0s + +0.0s +other 15 lifespans + +8.3s + +0.9s +import + lifespans floor +before +after + \ No newline at end of file diff --git a/docs/perf/winv2/make_graphs.py b/docs/perf/winv2/make_graphs.py new file mode 100644 index 00000000..353527a6 --- /dev/null +++ b/docs/perf/winv2/make_graphs.py @@ -0,0 +1,190 @@ +"""Dependency-free SVG charts for the winv2 perf baseline. + +No matplotlib/pandas (not in the bundled env). Reads baseline_startup.csv and +writes two self-contained SVGs that render in a browser, GitHub, or Notion: + baseline_startup.svg - backend-http-ready per launch (warm vs cold) + baseline_phases.svg - where the time goes (app-launch / first-paint / backend) +Run: python make_graphs.py +""" +import csv +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +CSV = os.path.join(HERE, "baseline_startup.csv") + +WARM = "#2e9e5b" +COLD = "#d64545" +INK = "#1a1d27" +MUTE = "#8892a4" +GRID = "#e2e6ef" + + +def rows(): + with open(CSV, newline="", encoding="utf-8") as f: + return list(csv.DictReader(f)) + + +def bars_chart(data): + w, h = 900, 420 + pad_l, pad_b, pad_t, pad_r = 60, 90, 50, 20 + plot_w = w - pad_l - pad_r + plot_h = h - pad_t - pad_b + vals = [int(r["backend_http_ready_ms"]) for r in data] + vmax = max(vals) + n = len(data) + bw = plot_w / n * 0.7 + gap = plot_w / n + out = [f''] + out.append(f'' + 'backend-http-ready per launch (ms) - lower is better') + # y gridlines + for frac in (0, 0.25, 0.5, 0.75, 1.0): + yv = vmax * frac + y = pad_t + plot_h - plot_h * frac + out.append(f'') + out.append(f'{yv/1000:.0f}s') + for i, r in enumerate(data): + v = int(r["backend_http_ready_ms"]) + bh = plot_h * v / vmax + x = pad_l + i * gap + (gap - bw) / 2 + y = pad_t + plot_h - bh + color = COLD if r["class"] == "cold" else WARM + out.append(f'') + out.append(f'{v/1000:.0f}s') + out.append(f'{r["version"]}') + out.append(f'' + f'warm') + out.append(f'' + f'cold (post-update)') + out.append('') + return "\n".join(out) + + +def phases_chart(data): + warm = [r for r in data if r["class"] == "warm"] + cold = [r for r in data if r["class"] == "cold"] + + def med(rows_, key): + xs = sorted(int(r[key]) for r in rows_) + return xs[len(xs) // 2] if xs else 0 + + cases = [ + ("typical warm launch", med(warm, "app_launch_ms"), med(warm, "first_paint_ms"), med(warm, "backend_http_ready_ms")), + ("typical cold launch", med(cold, "app_launch_ms"), med(cold, "first_paint_ms"), med(cold, "backend_http_ready_ms")), + ] + w, h = 900, 260 + pad_l, pad_r, pad_t = 170, 30, 50 + plot_w = w - pad_l - pad_r + vmax = max(c[3] for c in cases) + out = [f''] + out.append(f'' + 'where startup time goes (backend dwarfs the shell)') + row_h = 46 + for i, (label, al, fp, br) in enumerate(cases): + y = pad_t + i * (row_h + 26) + out.append(f'{label}') + # backend is the full bar; app-launch+first-paint are the tiny left slice + bw_backend = plot_w * br / vmax + out.append(f'') + shell = al + fp + bw_shell = plot_w * shell / vmax + out.append(f'') + out.append(f'' + f'backend {br/1000:.1f}s (shell {shell/1000:.2f}s)') + out.append(f'' + 'dark = electron shell (app-launch + first-paint); colored = python backend') + out.append('') + return "\n".join(out) + + +def boot_chart(): + """Before/after grouped bars for the boot-phase breakdown (profile_boot.py).""" + path = os.path.join(HERE, "boot_breakdown.csv") + with open(path, newline="", encoding="utf-8") as f: + data = list(csv.DictReader(f)) + w, h = 900, 360 + pad_l, pad_r, pad_t, pad_b = 60, 30, 50, 120 + plot_w = w - pad_l - pad_r + plot_h = h - pad_t - pad_b + vmax = max(max(int(r["before_ms"]), int(r["after_ms"])) for r in data) + n = len(data) + group = plot_w / n + bw = group * 0.34 + out = [f''] + out.append(f'' + 'warm boot breakdown: before vs after (ms) - the service lifespan was the bottleneck') + for frac in (0, 0.5, 1.0): + y = pad_t + plot_h - plot_h * frac + out.append(f'') + out.append(f'{vmax*frac/1000:.1f}s') + for i, r in enumerate(data): + bx = pad_l + i * group + group / 2 + for j, (key, color, lab) in enumerate((("before_ms", COLD, "before"), ("after_ms", WARM, "after"))): + v = int(r[key]) + bh = plot_h * v / vmax + x = bx + (j - 1) * bw - bw * 0.05 + y = pad_t + plot_h - bh + out.append(f'') + out.append(f'{v/1000:.1f}s') + out.append(f'{r["phase"]}') + out.append(f'before') + out.append(f'after') + out.append('') + return "\n".join(out) + + +def appbuilder_chart(): + """Horizontal bars for the App Builder create-path breakdown. Returns None + if the measurement CSV hasn't been generated yet.""" + path = os.path.join(HERE, "appbuilder_breakdown.csv") + if not os.path.exists(path): + return None + with open(path, newline="", encoding="utf-8") as f: + raw = list(csv.DictReader(f)) + # Keep only real timing phases (drop the boolean/skipped/-1 rows). + data = [r for r in raw if r["ms"].lstrip("-").isdigit() and int(r["ms"]) >= 0 + and not r["phase"].strip().startswith("->")] + if not data: + return None + w = 980 + row_h, gap, pad_t, pad_l, pad_r = 30, 14, 56, 320, 90 + h = pad_t + len(data) * (row_h + gap) + 30 + vmax = max(int(r["ms"]) for r in data) or 1 + plot_w = w - pad_l - pad_r + out = [f''] + out.append(f'' + 'App Builder "create app -> live preview" breakdown (ms)') + for i, r in enumerate(data): + v = int(r["ms"]) + y = pad_t + i * (row_h + gap) + bw = max(plot_w * v / vmax, 1) + # download/npm = cold cost (red-ish), everything else = warm/per-app (green) + cold = ("npm" in r["phase"]) or ("cold" in r["phase"]) + color = COLD if cold else WARM + out.append(f'{r["phase"]}') + out.append(f'') + label = f'{v/1000:.2f}s' if v >= 1000 else f'{v}ms' + out.append(f'{label}') + out.append(f'' + 'green = warm/per-app cost; red = cold one-time download (npm with no archive)') + out.append('') + return "\n".join(out) + + +def main(): + data = rows() + open(os.path.join(HERE, "baseline_startup.svg"), "w", encoding="utf-8").write(bars_chart(data)) + open(os.path.join(HERE, "baseline_phases.svg"), "w", encoding="utf-8").write(phases_chart(data)) + open(os.path.join(HERE, "boot_breakdown.svg"), "w", encoding="utf-8").write(boot_chart()) + wrote = "baseline_startup.svg + baseline_phases.svg + boot_breakdown.svg" + ab = appbuilder_chart() + if ab: + open(os.path.join(HERE, "appbuilder_breakdown.svg"), "w", encoding="utf-8").write(ab) + wrote += " + appbuilder_breakdown.svg" + print("wrote " + wrote) + + +if __name__ == "__main__": + main() diff --git a/docs/perf/winv2/measure_appbuilder.py b/docs/perf/winv2/measure_appbuilder.py new file mode 100644 index 00000000..8e37fc2b --- /dev/null +++ b/docs/perf/winv2/measure_appbuilder.py @@ -0,0 +1,135 @@ +"""Granular App Builder first-app create/"download" profiler (winv2 Task #3). + +Incremental + bounded: each phase appends to appbuilder_breakdown.csv and flushes +the instant it finishes, so a slow/hung later phase can't erase earlier numbers. +Run UNBUFFERED (python -u) so progress is visible mid-run. Cheap phases first. + +Phases: + 1. seed workspace + link node_modules (per-app cost, uses real warm cache) + 2. download: npm install (cold, no archive) (the "slow as bricks" download) + 3. download: archive extract (new build path) (tar the just-installed nm, time extract) + 4. vite bind: cold vite cache (first app ever) + 5. vite bind: warm shared cache (subsequent apps) + +Isolated temp dirs; never mutates the user's real caches (read-only link to the +warm node_modules cache; vite cache is overridden to temp for the cold case). +""" +import asyncio +import os +import shutil +import subprocess +import tarfile +import tempfile +import time + +from backend.apps.outputs import view_builder_templates as vt +from backend.apps.outputs.runtime_proc import _find_free_port +from backend.apps.outputs.runtime import AppRuntime + +HERE = os.path.dirname(os.path.abspath(__file__)) +CSV = os.path.join(HERE, "appbuilder_breakdown.csv") +TMP = tempfile.mkdtemp(prefix="ab-measure-") +TMPL_FRONTEND = os.path.join(vt.WEBAPP_TEMPLATE_DIR, "frontend") +VITE_DEADLINE = 90 + +with open(CSV, "w", encoding="utf-8") as f: + f.write("phase,ms,note\n") + + +def lap(t): + return round((time.perf_counter() - t) * 1000) + + +def record(name, ms, note=""): + print(f"{ms:8d} ms {name}" + (f" ({note})" if note else ""), flush=True) + with open(CSV, "a", encoding="utf-8") as f: + f.write(f'"{name}",{ms},"{note}"\n') + f.flush() + + +def phase_seed(): + ws = os.path.join(TMP, "ws-seed") + t = time.perf_counter() + vt.seed_webapp_template_workspace(ws, _find_free_port()) + ms = lap(t) + present = os.path.exists(os.path.join(ws, "frontend", "node_modules")) + record("seed workspace + link node_modules (per app)", ms, "nm linked" if present else "NO nm") + + +def phase_npm_and_extract(): + npm = vt._resolve_npm() + if not npm: + record("download: npm install (cold)", -1, "skipped: no npm") + return + work = os.path.join(TMP, "npm_cold") + os.makedirs(work, exist_ok=True) + shutil.copyfile(os.path.join(TMPL_FRONTEND, "package.json"), os.path.join(work, "package.json")) + lock = os.path.join(TMPL_FRONTEND, "package-lock.json") + cmd = [*npm, "install", "--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"] + if os.path.exists(lock): + shutil.copyfile(lock, os.path.join(work, "package-lock.json")) + cmd = [*npm, "ci", "--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"] + t = time.perf_counter() + try: + r = subprocess.run(cmd, cwd=work, capture_output=True, text=True, timeout=240) + record("download: npm install (cold, no archive)", lap(t), "ok" if r.returncode == 0 else f"rc={r.returncode}") + except subprocess.TimeoutExpired: + record("download: npm install (cold, no archive)", -1, "TIMEOUT 240s") + return + + nm = os.path.join(work, "node_modules") + if not os.path.isdir(nm): + return + # Reuse that node_modules to time the archive build + extract (new path). + archive = os.path.join(TMP, "nm.tar.gz") + t = time.perf_counter() + with tarfile.open(archive, "w:gz") as tar: + tar.add(nm, arcname="node_modules") + record("build-time: tar node_modules -> archive", lap(t), f"{os.path.getsize(archive)//(1024*1024)}MB") + exd = os.path.join(TMP, "extract"); os.makedirs(exd, exist_ok=True) + t = time.perf_counter() + with tarfile.open(archive, "r:gz") as tar: + tar.extractall(exd) + record("download: archive extract (new build path)", lap(t)) + + +async def _bind_once(label, vite_cache_dir): + ws = os.path.join(TMP, f"ws-{label}") + vt.seed_webapp_template_workspace(ws, _find_free_port()) + if vite_cache_dir: + os.environ["OPENSWARM_VITE_CACHE_DIR"] = vite_cache_dir + else: + os.environ.pop("OPENSWARM_VITE_CACHE_DIR", None) + rt = AppRuntime(f"ws-{label}", ws) + t = time.perf_counter() + await rt.start() + deadline = time.perf_counter() + VITE_DEADLINE + while rt.frontend_url is None and time.perf_counter() < deadline: + await asyncio.sleep(0.1) + bound = rt.frontend_url is not None + ms = lap(t) if bound else -1 + try: + await rt.stop() + except Exception: + pass + record(f"vite bind ({label})", ms, "bound" if bound else f"TIMEOUT {VITE_DEADLINE}s") + + +async def main(): + print(f"temp: {TMP}", flush=True) + for fn in (phase_seed, phase_npm_and_extract): + try: + fn() + except Exception as e: + record(fn.__name__, -1, f"ERR {type(e).__name__}: {e}") + for label, cache in (("cold vite cache", os.path.join(TMP, "vite_cold")), ("warm shared cache", None)): + try: + await _bind_once(label, cache) + except Exception as e: + record(f"vite bind ({label})", -1, f"ERR {type(e).__name__}: {e}") + print("done", flush=True) + shutil.rmtree(TMP, ignore_errors=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/perf/winv2/measure_vite.py b/docs/perf/winv2/measure_vite.py new file mode 100644 index 00000000..8bc9ef57 --- /dev/null +++ b/docs/perf/winv2/measure_vite.py @@ -0,0 +1,70 @@ +"""Vite-bind-only measurement (winv2 Task #3, part 2). + +Split out from measure_appbuilder.py because Python's tarfile gzip of a full +node_modules is pathologically slow and was eating the time budget before the +vite phases ran. This does ONLY the two vite binds (cold vite cache = first app +ever; warm shared cache = subsequent apps) and APPENDS to appbuilder_breakdown.csv. +No tar, no npm. Run unbuffered. +""" +import asyncio +import os +import shutil +import tempfile +import time + +from backend.apps.outputs import view_builder_templates as vt +from backend.apps.outputs.runtime_proc import _find_free_port +from backend.apps.outputs.runtime import AppRuntime + +HERE = os.path.dirname(os.path.abspath(__file__)) +CSV = os.path.join(HERE, "appbuilder_breakdown.csv") +TMP = tempfile.mkdtemp(prefix="ab-vite-") +VITE_DEADLINE = 100 + + +def record(name, ms, note=""): + print(f"{ms:8d} ms {name}" + (f" ({note})" if note else ""), flush=True) + with open(CSV, "a", encoding="utf-8") as f: + f.write(f'"{name}",{ms},"{note}"\n') + f.flush() + + +async def bind_once(label, vite_cache_dir): + ws = os.path.join(TMP, f"ws-{label.replace(' ', '_')}") + vt.seed_webapp_template_workspace(ws, _find_free_port()) + if not os.path.exists(os.path.join(ws, "frontend", "node_modules")): + record(f"vite bind ({label})", -1, "no node_modules linked") + return + if vite_cache_dir: + os.environ["OPENSWARM_VITE_CACHE_DIR"] = vite_cache_dir + else: + os.environ.pop("OPENSWARM_VITE_CACHE_DIR", None) + rt = AppRuntime(f"ws-{label}", ws) + t = time.perf_counter() + await rt.start() + deadline = time.perf_counter() + VITE_DEADLINE + while rt.frontend_url is None and time.perf_counter() < deadline: + await asyncio.sleep(0.1) + bound = rt.frontend_url is not None + ms = round((time.perf_counter() - t) * 1000) if bound else -1 + try: + await rt.stop() + except Exception: + pass + record(f"vite bind ({label})", ms, "bound" if bound else f"TIMEOUT {VITE_DEADLINE}s") + + +async def main(): + print(f"temp: {TMP}", flush=True) + for label, cache in (("cold vite cache", os.path.join(TMP, "vite_cold")), + ("warm shared cache", None)): + try: + await bind_once(label, cache) + except Exception as e: + record(f"vite bind ({label})", -1, f"ERR {type(e).__name__}: {e}") + print("done", flush=True) + shutil.rmtree(TMP, ignore_errors=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/perf/winv2/profile_boot.py b/docs/perf/winv2/profile_boot.py new file mode 100644 index 00000000..86a49f10 --- /dev/null +++ b/docs/perf/winv2/profile_boot.py @@ -0,0 +1,67 @@ +"""Per-phase + per-SubApp-lifespan boot profiler (winv2). + +Warm import is ~1.3s but backend-http-ready is ~9-10s, so the gap is the +lifespan startup (SubApp lifespans are entered sequentially in config/Apps.py +before uvicorn serves). This times each one to find what blocks the HTTP bind. + +Run with the bundled interpreter from the resources dir, e.g.: + python-env/python.exe docs/perf/winv2/profile_boot.py +It spawns the same subprocesses a real boot does (9router etc.); the +AsyncExitStack unwinds at the end. Kill any straggler node/9router after. +""" +import asyncio +import os +import time + +os.environ.setdefault("OPENSWARM_AUTH_TOKEN", "x") + +_t0 = time.perf_counter() +import backend.main # noqa: F401 (builds main_app; full import tree) +_import_ms = (time.perf_counter() - _t0) * 1000 + +from contextlib import AsyncExitStack # noqa: E402 + +from backend.apps.health.health import health # noqa: E402 +from backend.apps.agents.agents import agents # noqa: E402 +from backend.apps.skills.skills import skills # noqa: E402 +from backend.apps.tools_lib.tools_lib import tools_lib # noqa: E402 +from backend.apps.modes.modes import modes # noqa: E402 +from backend.apps.settings.settings import settings # noqa: E402 +from backend.apps.mcp_registry.mcp_registry import mcp_registry # noqa: E402 +from backend.apps.skill_registry.skill_registry import skill_registry # noqa: E402 +from backend.apps.outputs.outputs import outputs # noqa: E402 +from backend.apps.dashboards.dashboards import dashboards # noqa: E402 +from backend.apps.swarm.swarm import swarm # noqa: E402 +from backend.apps.service.service import service # noqa: E402 +from backend.apps.subscription.router import subscription # noqa: E402 +from backend.apps.auth.router import auth # noqa: E402 +from backend.apps.web.web import web # noqa: E402 +from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy # noqa: E402 + +SUBS = [health, agents, skills, tools_lib, modes, settings, mcp_registry, + skill_registry, outputs, dashboards, swarm, service, subscription, + auth, web, anthropic_proxy] + + +async def main(): + print(f"{_import_ms:8.0f} ms import backend.main (full tree)") + print("-" * 48) + total = 0.0 + async with AsyncExitStack() as stack: + for s in SUBS: + t = time.perf_counter() + try: + await asyncio.wait_for(stack.enter_async_context(s.lifespan()), timeout=60) + except Exception as e: + print(f" ERR lifespan {s.name}: {type(e).__name__}") + continue + dt = (time.perf_counter() - t) * 1000 + total += dt + print(f"{dt:8.0f} ms lifespan {s.name}") + print("-" * 48) + print(f"{total:8.0f} ms all lifespans") + print(f"{_import_ms + total:8.0f} ms import + lifespans (approx backend-ready floor)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/perf/winv2/profile_startup.sh b/docs/perf/winv2/profile_startup.sh new file mode 100644 index 00000000..259a1a2c --- /dev/null +++ b/docs/perf/winv2/profile_startup.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Re-extract real packaged-app startup timings from the installed app's backend +# log. Prints one row per launch: timestamp, version, app-launch ms, +# first-paint ms, backend-http-ready ms. Pipe to a CSV for the metrics table. +# +# Usage: bash profile_startup.sh [path-to-backend.log] +# Default log: AppData/Roaming/openswarm/data/backend.log + +LOG="${1:-$HOME/AppData/Roaming/openswarm/data/backend.log}" +if [[ ! -f "$LOG" ]]; then + echo "no backend.log at $LOG" >&2 + exit 1 +fi + +echo "launch_ts,version,app_launch_ms,first_paint_ms,backend_http_ready_ms,class" +awk ' + /===== launch/ { + if (ts != "") emit() + ts=$3; ver="" + for (i=1;i<=NF;i++) if ($i ~ /^\(app$/) { ver=$(i+1); gsub(/,/,"",ver) } + al=""; fp=""; br="" + } + /\[perf\] app-launch t=/ { sub(/.*t=/,""); al=$0 } + /\[perf\] first-paint t=/ { sub(/.*t=/,""); fp=$0 } + /\[perf\] backend-http-ready t=/ { sub(/.*t=/,""); br=$0 } + END { if (ts != "") emit() } + function emit() { + cls = (br+0 > 20000) ? "cold" : "warm" + printf "%s,%s,%s,%s,%s,%s\n", ts, ver, al, fp, br, cls + } +' "$LOG" From ccc06ea18844c78156a2182ef7c97765f6ad7a7d Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:58:59 -0700 Subject: [PATCH 039/174] [eric] skills: seed registry from bundled snapshot + disk cache, retry until first fetch - fixes empty skills list until reboot and onboarding skill-item-pdf timeout - catalog never empty offline; add regression test, 3 cases --- backend/apps/skill_registry/skill_registry.py | 79 +++++++++- .../apps/skill_registry/skills_snapshot.json | 138 ++++++++++++++++++ backend/tests/test_skill_registry_seed.py | 46 ++++++ 3 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 backend/apps/skill_registry/skills_snapshot.json create mode 100644 backend/tests/test_skill_registry_seed.py diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py index 306880e4..05717d9c 100644 --- a/backend/apps/skill_registry/skill_registry.py +++ b/backend/apps/skill_registry/skill_registry.py @@ -1,5 +1,7 @@ import asyncio +import json import logging +import os import re import time from contextlib import asynccontextmanager @@ -17,12 +19,62 @@ RAW_BASE = f"https://raw.githubusercontent.com/{REPO}/{BRANCH}" MANIFEST_URL = f"{RAW_BASE}/.claude-plugin/marketplace.json" REFRESH_INTERVAL_S = 3600 CONCURRENT_FETCHES = 15 +# Retry the startup fetch on this short backoff (capped) until the FIRST success, +# instead of waiting a full REFRESH_INTERVAL_S after a cold/slow/failed fetch. +# That 1h gap was the "skills empty until reboot" bug on cold Windows networks. +_RETRY_BACKOFF_START_S = 2 +_RETRY_BACKOFF_MAX_S = 60 + +# Catalog ships in the repo so a brand-new install shows skills with zero network +# (build snapshot), and every successful live fetch is persisted to the user's +# cache so subsequent launches are instant + offline-safe. The live fetch always +# overwrites both once it lands, so neither can go stale at runtime. +_BUNDLED_SNAPSHOT = os.path.join(os.path.dirname(__file__), "skills_snapshot.json") _cache: dict[str, dict] = {} _cache_updated_at: float = 0 _refresh_task: Optional[asyncio.Task] = None +def _disk_cache_path() -> str: + base = os.environ.get("OPENSWARM_SKILL_CACHE_DIR") or os.path.expanduser( + "~/.openswarm/cache" + ) + return os.path.join(base, "skill_registry.json") + + +def _load_seed_cache() -> dict[str, dict]: + """Return a non-empty catalog from the on-disk last-good cache, falling back + to the bundled snapshot, so the registry is never empty on a cold/offline + start. Returns {} only if neither source is present/valid.""" + for path in (_disk_cache_path(), _BUNDLED_SNAPSHOT): + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and data: + logger.info(f"Skill registry: seeded {len(data)} skills from {os.path.basename(path)}") + return data + except (OSError, ValueError): + continue + return {} + + +def _save_disk_cache(skills: dict[str, dict]) -> None: + """Persist the last good live fetch so the next launch is instant. Atomic + replace so a crash mid-write can't leave a truncated cache.""" + if not skills: + return + path = _disk_cache_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(skills, f) + os.replace(tmp, path) + except OSError: + logger.debug("Skill registry: could not persist disk cache", exc_info=True) + + def _parse_frontmatter(raw: str) -> tuple[dict, str]: """Split YAML frontmatter from markdown body.""" if not raw.startswith("---"): @@ -114,18 +166,37 @@ async def _fetch_all_skills() -> dict[str, dict]: async def _refresh_loop(): global _cache, _cache_updated_at + backoff = _RETRY_BACKOFF_START_S while True: + ok = False try: - _cache = await _fetch_all_skills() - _cache_updated_at = time.time() + fetched = await _fetch_all_skills() + if fetched: + _cache = fetched + _cache_updated_at = time.time() + _save_disk_cache(_cache) + ok = True except Exception as e: logger.exception(f"Skill registry refresh error: {e}") - await asyncio.sleep(REFRESH_INTERVAL_S) + if ok: + # Settle to the slow hourly refresh once we have a good catalog. + backoff = _RETRY_BACKOFF_START_S + await asyncio.sleep(REFRESH_INTERVAL_S) + else: + # Cold/slow/failed fetch: retry soon (capped) until the first success + # so a transient network hiccup doesn't leave the catalog empty for + # an hour. The seeded snapshot keeps it non-empty meanwhile. + await asyncio.sleep(backoff) + backoff = min(backoff * 2, _RETRY_BACKOFF_MAX_S) @asynccontextmanager async def skill_registry_lifespan(): - global _refresh_task + global _refresh_task, _cache + # Seed instantly from disk/bundled snapshot so the very first request never + # sees an empty catalog (the live fetch below overwrites it when it lands). + if not _cache: + _cache = _load_seed_cache() _refresh_task = asyncio.create_task(_refresh_loop()) yield if _refresh_task: diff --git a/backend/apps/skill_registry/skills_snapshot.json b/backend/apps/skill_registry/skills_snapshot.json new file mode 100644 index 00000000..36cfd9c4 --- /dev/null +++ b/backend/apps/skill_registry/skills_snapshot.json @@ -0,0 +1,138 @@ +{ + "algorithmic-art": { + "category": "Example Skills", + "content": "", + "description": "Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.", + "folder": "skills/algorithmic-art", + "name": "algorithmic-art", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/algorithmic-art" + }, + "brand-guidelines": { + "category": "Example Skills", + "content": "", + "description": "Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.", + "folder": "skills/brand-guidelines", + "name": "brand-guidelines", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/brand-guidelines" + }, + "canvas-design": { + "category": "Example Skills", + "content": "", + "description": "Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.", + "folder": "skills/canvas-design", + "name": "canvas-design", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/canvas-design" + }, + "claude-api": { + "category": "Claude Api", + "content": "", + "description": "|-", + "folder": "skills/claude-api", + "name": "claude-api", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/claude-api" + }, + "doc-coauthoring": { + "category": "Example Skills", + "content": "", + "description": "Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.", + "folder": "skills/doc-coauthoring", + "name": "doc-coauthoring", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/doc-coauthoring" + }, + "docx": { + "category": "Document Skills", + "content": "", + "description": "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation.", + "folder": "skills/docx", + "name": "docx", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/docx" + }, + "frontend-design": { + "category": "Example Skills", + "content": "", + "description": "Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.", + "folder": "skills/frontend-design", + "name": "frontend-design", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/frontend-design" + }, + "internal-comms": { + "category": "Example Skills", + "content": "", + "description": "A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).", + "folder": "skills/internal-comms", + "name": "internal-comms", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/internal-comms" + }, + "mcp-builder": { + "category": "Example Skills", + "content": "", + "description": "Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).", + "folder": "skills/mcp-builder", + "name": "mcp-builder", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/mcp-builder" + }, + "pdf": { + "category": "Document Skills", + "content": "", + "description": "Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.", + "folder": "skills/pdf", + "name": "pdf", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/pdf" + }, + "pptx": { + "category": "Document Skills", + "content": "", + "description": "Use this skill any time a .pptx file is involved in any way \u2014 as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \\\"deck,\\\" \\\"slides,\\\" \\\"presentation,\\\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill.", + "folder": "skills/pptx", + "name": "pptx", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/pptx" + }, + "skill-creator": { + "category": "Example Skills", + "content": "", + "description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.", + "folder": "skills/skill-creator", + "name": "skill-creator", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/skill-creator" + }, + "slack-gif-creator": { + "category": "Example Skills", + "content": "", + "description": "Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like \"make me a GIF of X doing Y for Slack.", + "folder": "skills/slack-gif-creator", + "name": "slack-gif-creator", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/slack-gif-creator" + }, + "theme-factory": { + "category": "Example Skills", + "content": "", + "description": "Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.", + "folder": "skills/theme-factory", + "name": "theme-factory", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/theme-factory" + }, + "web-artifacts-builder": { + "category": "Example Skills", + "content": "", + "description": "Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.", + "folder": "skills/web-artifacts-builder", + "name": "web-artifacts-builder", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/web-artifacts-builder" + }, + "webapp-testing": { + "category": "Example Skills", + "content": "", + "description": "Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.", + "folder": "skills/webapp-testing", + "name": "webapp-testing", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/webapp-testing" + }, + "xlsx": { + "category": "Document Skills", + "content": "", + "description": "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path \u2014 even casually (like \\\"the xlsx in my downloads\\\") \u2014 and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved.", + "folder": "skills/xlsx", + "name": "xlsx", + "repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/xlsx" + } +} \ No newline at end of file diff --git a/backend/tests/test_skill_registry_seed.py b/backend/tests/test_skill_registry_seed.py new file mode 100644 index 00000000..ed94d11a --- /dev/null +++ b/backend/tests/test_skill_registry_seed.py @@ -0,0 +1,46 @@ +"""Regression tests for the skill-registry never-empty seed (winv2 Bug #1). + +The bug: the catalog was fetched from GitHub once at startup then only hourly, +so a cold/slow/failed network left it empty for the whole session, breaking the +Skills page and the onboarding "Install a skill" step (waitForSelector +"skill-item-pdf" timing out). Fix: seed from a bundled snapshot + on-disk +last-good cache so the catalog is never empty, even fully offline. +""" +import asyncio +import json +import os + +from backend.apps.skill_registry import skill_registry as sr + + +def test_bundled_snapshot_exists_and_includes_pdf(): + # The onboarding step targets the "pdf" skill via /pdf/i; it must be present + # in the shipped snapshot or the tour times out even with a populated list. + assert os.path.exists(sr._BUNDLED_SNAPSHOT) + data = json.load(open(sr._BUNDLED_SNAPSHOT, encoding="utf-8")) + assert isinstance(data, dict) and len(data) >= 10 + assert any("pdf" in k.lower() or "pdf" in v.get("folder", "").lower() + for k, v in data.items()) + + +def test_seed_makes_catalog_non_empty_offline(monkeypatch, tmp_path): + # Point the disk cache at an empty tmp dir so only the bundled snapshot can + # seed; this is the brand-new-install, no-network case. + monkeypatch.setenv("OPENSWARM_SKILL_CACHE_DIR", str(tmp_path)) + seeded = sr._load_seed_cache() + assert len(seeded) >= 10 + + sr._cache = seeded + res = asyncio.run(sr.registry_search(q="", limit=100, offset=0, sort="name", category="")) + assert res["total"] >= 10 and len(res["skills"]) >= 10 + + +def test_disk_cache_roundtrip_and_priority(monkeypatch, tmp_path): + # A saved last-good fetch must win over the bundled snapshot on next boot. + monkeypatch.setenv("OPENSWARM_SKILL_CACHE_DIR", str(tmp_path)) + sentinel = {"only-skill": {"name": "only-skill", "description": "", "content": "", + "folder": "skills/only-skill", "category": "Test", + "repositoryUrl": ""}} + sr._save_disk_cache(sentinel) + assert os.path.exists(sr._disk_cache_path()) + assert sr._load_seed_cache() == sentinel From fe9f8d7105efb37b89a3ad8948dd77b933fbc0a9 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:58:59 -0700 Subject: [PATCH 040/174] [eric] app-builder: windows node_modules link fallback + prefer pre-extracted bundle - _link_node_modules falls back symlink -> junction -> copy (no admin needed) - _ensure_warm_cache prefers a pre-extracted resources tree, zero first-app extract (#9 item 2) - falls back to tar/npm when absent so mac/older builds are unchanged; add tests --- .../apps/outputs/view_builder_templates.py | 59 ++++++++++++++++++- .../tests/test_bundled_extracted_modules.py | 33 +++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_bundled_extracted_modules.py diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 3cc089f7..e5715b95 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -214,6 +214,19 @@ def _bundled_archive_path_for(digest: str) -> str: return os.path.join(_BUNDLED_ARCHIVE_DIR, f"node_modules.{digest}.tar.gz") +def _bundled_extracted_modules() -> str | None: + """A node_modules tree shipped ALREADY EXTRACTED in resources (digest-tagged), + so a workspace can junction straight at it with ZERO extract. This skips the + ~14s first-app tar-extract on Windows (the extract is dominated by Defender + scanning ~tens of thousands of small files as they're written; shipping it + extracted moves that scan to install time, once). Returns the read-only path + or None when no extracted tree is shipped (e.g. the Mac build, which ships + the .tar.gz and uses the extract path instead). vite only reads node_modules + (its optimize cache lives elsewhere), so a read-only shared tree is safe.""" + cand = os.path.join(_BUNDLED_ARCHIVE_DIR, _warm_cache_digest(), "node_modules") + return cand if os.path.isdir(cand) else None + + def _try_extract_bundled_archive(cache_dir: str, digest: str) -> bool: """Unpack the sha-tagged bundled archive into `cache_dir` if one exists for the current template digest. Returns True on success, @@ -284,6 +297,13 @@ def _ensure_warm_cache() -> str | None: if os.path.isdir(cache_modules): return cache_modules + # Prefer a pre-extracted bundled tree: junction the workspace straight at it, + # no tar-extract and no npm. This is the #9 first-app speed win on Windows. + bundled = _bundled_extracted_modules() + if bundled: + logger.info("webapp-template: using bundled pre-extracted node_modules (zero extract)") + return bundled + with _warm_cache_lock: if os.path.isdir(cache_modules): return cache_modules @@ -344,6 +364,38 @@ def _ensure_warm_cache() -> str | None: return None +def _try_link_dir(src: str, target: str) -> bool: + """Point `target` at `src` as cheaply as possible. Prefer a symlink (instant, + shared, zero disk). On Windows os.symlink needs admin / Developer Mode, which + a normal user account lacks, so fall back to a directory junction (mklink /J, + no privilege required), then to a full copy as a last resort so even a + locked-down Windows box ends up with a usable node_modules. Returns True if + `target` now resolves to the dependency tree.""" + try: + os.symlink(src, target) + return True + except OSError: + pass + if os.name == "nt": + try: + r = subprocess.run( + ["cmd", "/c", "mklink", "/J", target, src], + capture_output=True, text=True, timeout=15, + ) + if r.returncode == 0 and os.path.isdir(target): + return True + except Exception: + pass + try: + # Slow + uses disk, but guarantees the workspace can boot vite even when + # neither symlink nor junction is available. + shutil.copytree(src, target, dirs_exist_ok=True) + return True + except OSError as exc: + logger.warning("webapp-template link/copy failed (%s) for %s", exc, target) + return False + + def _link_node_modules(workspace_dir: str) -> None: """After copytree, point the workspace's frontend/node_modules at the warm-cache directory. Safe fallback; if the cache isn't ready, @@ -379,10 +431,11 @@ def _link_node_modules(workspace_dir: str) -> None: return try: os.makedirs(os.path.dirname(target), exist_ok=True) - os.symlink(cache_modules, target) - logger.info("webapp-template: linked %s -> %s", target, cache_modules) except OSError as exc: - logger.warning("webapp-template symlink failed (%s) for %s", exc, workspace_dir) + logger.warning("webapp-template mkdir failed (%s) for %s", exc, workspace_dir) + return + if _try_link_dir(cache_modules, target): + logger.info("webapp-template: linked %s -> %s", target, cache_modules) # --------------------------------------------------------------------------- diff --git a/backend/tests/test_bundled_extracted_modules.py b/backend/tests/test_bundled_extracted_modules.py new file mode 100644 index 00000000..6d3b1e22 --- /dev/null +++ b/backend/tests/test_bundled_extracted_modules.py @@ -0,0 +1,33 @@ +"""Tests for the #9 item 2 pre-extracted node_modules path. + +The packaged Windows build ships node_modules ALREADY EXTRACTED in resources so a +workspace junctions straight at it with zero first-app tar-extract. _ensure_warm_cache +must prefer that tree over the .tar.gz / npm paths, and must be a no-op (return None) +when no tree is shipped so Mac and older builds fall back unchanged. +""" +import os + +from backend.apps.outputs import view_builder_templates as vt + + +def test_prefers_bundled_extracted_tree(monkeypatch, tmp_path): + digest = vt._warm_cache_digest() + # Force a home-cache miss so we exercise the bundled path. + monkeypatch.setenv("OPENSWARM_WEBAPP_CACHE_DIR", str(tmp_path / "home")) + bundle = tmp_path / "resources_cache" + monkeypatch.setattr(vt, "_BUNDLED_ARCHIVE_DIR", str(bundle)) + nm = bundle / digest / "node_modules" / "vite" / "bin" + nm.mkdir(parents=True) + (nm / "vite.js").write_text("// fake") + + expected = str(bundle / digest / "node_modules") + assert vt._bundled_extracted_modules() == expected + # Zero extract / zero npm: returns the read-only resources tree directly. + assert vt._ensure_warm_cache() == expected + + +def test_no_bundled_tree_returns_none(monkeypatch, tmp_path): + # No extracted tree shipped (Mac / older builds): must not select it, so the + # caller falls through to the .tar.gz extract or live npm. + monkeypatch.setattr(vt, "_BUNDLED_ARCHIVE_DIR", str(tmp_path / "empty")) + assert vt._bundled_extracted_modules() is None From 1facda5c33139a511a6c96517ad3053a60acf1d0 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:58:59 -0700 Subject: [PATCH 041/174] [eric] app-builder: spawn vite directly via bundled node on windows (no bash) - frontend-only apps no longer need git bash; kills the [WinError 2] preview failure - windows-only, falls back to bash run.sh for backend apps or when vite is absent --- backend/apps/outputs/runtime.py | 34 ++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index b83b9abd..a52f955f 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -264,12 +264,13 @@ class AppRuntime: env["OPENSWARM_DEBUGGER_PATH"] = _DEBUGGER_PATH env["OPENSWARM_TEMPLATE_BACKEND_PATH"] = _TEMPLATE_BACKEND_PATH + cmd, spawn_cwd, launch_desc = self._resolve_launch(env) try: self.process = await asyncio.create_subprocess_exec( - _resolve_bash(), "run.sh", + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - cwd=self.workspace_path, + cwd=spawn_cwd, env=env, **_background_priority_kwargs(), ) @@ -281,7 +282,7 @@ class AppRuntime: self.process = None return False backend_note = f" + backend on {self.port}" if self.port else "" - self._broadcast(LogLine("runtime", f"[runtime] bash run.sh started; frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})")) + self._broadcast(LogLine("runtime", f"[runtime] {launch_desc} started; frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})")) self._stdout_task = asyncio.create_task(self._pipe_stream(self.process.stdout, "stdout")) self._stderr_task = asyncio.create_task(self._pipe_stream(self.process.stderr, "stderr")) self._wait_task = asyncio.create_task(self._await_exit()) @@ -291,6 +292,33 @@ class AppRuntime: self._frontend_ready_task = asyncio.create_task(self._await_frontend_bind()) return True + def _resolve_launch(self, env: dict) -> tuple[list[str], str, str]: + """Pick the new-mode launch command. + + Default is `bash run.sh` at the workspace root, which handles both + frontend-only and backend-enabled apps. On Windows we take a fast path + for frontend-only apps (the common case): run vite directly through the + bundled node, with no system `bash` at all. The packaged Windows build + ships node but not bash, so a user without Git for Windows hit + [WinError 2] on `bash run.sh` and the preview never started. We only + take this path when vite is actually present (node_modules linked); + otherwise fall back to bash so behavior is unchanged everywhere else. + vite.config.ts reads FRONTEND_PORT / BACKEND_PORT from the environment.""" + if os.name == "nt" and self.port is None: + node = env.get("OPENSWARM_NODE_PATH") or shutil.which("node") + vite_bin = os.path.join( + self.workspace_path, "frontend", "node_modules", "vite", "bin", "vite.js" + ) + if node and os.path.exists(node) and os.path.exists(vite_bin): + env["FRONTEND_PORT"] = str(self.frontend_port) + env["BACKEND_PORT"] = "NONE" + return ( + [node, "node_modules/vite/bin/vite.js"], + os.path.join(self.workspace_path, "frontend"), + "vite (bundled node, no bash)", + ) + return [_resolve_bash(), "run.sh"], self.workspace_path, "bash run.sh" + async def _await_frontend_bind(self) -> None: """Poll `frontend_port` every _FRONTEND_BIND_POLL_INTERVAL until something binds (Vite dev server) or we hit the timeout. Emits a From bca0050dcb1dede2bce49bad2734927d4a8a5693 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:59:16 -0700 Subject: [PATCH 042/174] [eric] perf: start 9router in background instead of blocking the http bind - service lifespan was awaiting ensure_9router (~7.4s, up to ~18s cold) on the boot path - dispatch already ensures it lazily; serialize ensure_running so no double-spawn - cuts warm backend-ready ~9-10s toward ~2-3s --- backend/apps/nine_router/process.py | 15 +++++++++++++++ backend/apps/service/service.py | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index b34a51e0..12935c00 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -57,6 +57,11 @@ NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60") _process: subprocess.Popen | None = None +# Serializes ensure_running() so a background auto-start and a concurrent +# dispatch-time ensure can't both spawn 9Router (double-bind on :20128). Lazily +# created so module import doesn't require a running event loop. +_start_lock: "asyncio.Lock | None" = None + # Short TTL cache for positive is_running() results. The probe is a sync # httpx.get that blocks the event loop, and under load (9Router busy # streaming inference) it can exceed its 2s timeout and return False even @@ -334,6 +339,16 @@ def _report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> No async def ensure_running(): + """Start 9Router if not already running. Serialized so concurrent callers + (the background auto-start + a dispatch-time ensure) can't double-spawn.""" + global _start_lock + if _start_lock is None: + _start_lock = asyncio.Lock() + async with _start_lock: + await _ensure_running_impl() + + +async def _ensure_running_impl(): """Start 9Router if not already running.""" global _process _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index 532eab62..93c70851 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -33,6 +33,7 @@ logger = logging.getLogger(__name__) _pulse_task: asyncio.Task | None = None _drain_task: asyncio.Task | None = None +_9r_start_task: asyncio.Task | None = None _last_9r_cost: float | None = None _last_9r_prompt_tokens: int | None = None @@ -121,7 +122,7 @@ async def _drain_loop(): @asynccontextmanager async def service_lifespan(): - global _pulse_task, _drain_task + global _pulse_task, _drain_task, _9r_start_task try: from backend.apps.settings.settings import load_settings, _save_settings @@ -193,7 +194,13 @@ async def service_lifespan(): try: from backend.apps.nine_router import ensure_running as ensure_9router - await ensure_9router() + # Start 9Router in the BACKGROUND instead of awaiting it here. Awaiting + # it was ~7s (up to ~18s cold) of the startup critical path, blocking the + # HTTP bind and the whole UI behind it. 9Router is only needed when the + # user sends an agent message, and the dispatch path calls ensure_running() + # itself (now serialized, so no double-spawn), so the first message waits + # for readiness lazily. This is the single biggest warm-startup win. + _9r_start_task = asyncio.create_task(ensure_9router()) except Exception as e: logger.debug(f"9Router auto-start skipped: {e}") @@ -218,6 +225,14 @@ async def service_lifespan(): pass _drain_task = None + if _9r_start_task and not _9r_start_task.done(): + _9r_start_task.cancel() + try: + await _9r_start_task + except (asyncio.CancelledError, Exception): + pass + _9r_start_task = None + try: from backend.apps.nine_router import stop as stop_9router stop_9router() From 9f0f561dde0ae1ed61449d270f7930016f7d36f6 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:59:16 -0700 Subject: [PATCH 043/174] [eric] app-builder: pre-extract webapp-template node_modules into windows resources - step 4b builds node_modules natively and robocopies it into resources (digest-tagged) - runtime junctions straight at it; kills the ~14s first-app extract; non-fatal on failure --- scripts/build-app-win.ps1 | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 9413c2f6..867ede0b 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -363,6 +363,56 @@ if (Test-Path $EnvExampleSrc) { Copy-Item -Force $EnvExampleSrc $EnvExampleDst Write-Host "Restored webapp_template/.env.example (stripped by the .env.* exclude)" } + +# --- Step 4b: Pre-EXTRACT the webapp-template node_modules into resources (Windows). +# The Windows build never shipped any node_modules, and the bundled node has no +# npm, so the App Builder frontend had no way to get its deps; the preview died +# with the misleading "backend exited with code 1". We ship the tree ALREADY +# EXTRACTED (digest-tagged) so the runtime junctions a workspace straight at it: +# zero first-app extract (the .tar.gz path cost ~14s of Defender-scanned writes +# on first app; #9). _ensure_warm_cache() / _bundled_extracted_modules() pick +# this up; the Mac build still ships the .tar.gz and uses the extract path. +# Built natively so the esbuild/rollup win32 binaries are correct (a Mac-built +# tree would ship darwin binaries and still fail). Non-fatal: a failure warns +# but doesn't break the build. Digest == _warm_cache_digest() (sha256 of +# frontend/package.json, first 12 hex chars). +Write-Host "[4b] Pre-extracting webapp-template node_modules into resources..." +try { + $TmplFrontend = Join-Path $Staging 'backend\apps\outputs\webapp_template\frontend' + $PkgJson = Join-Path $TmplFrontend 'package.json' + if (-not (Test-Path $PkgJson)) { throw "template package.json not found at $PkgJson" } + $Digest = (Get-FileHash -Algorithm SHA256 $PkgJson).Hash.ToLower().Substring(0, 12) + $DestNm = Join-Path $Staging "backend\apps\outputs\webapp_template_cache\$Digest\node_modules" + $WorkDir = Join-Path $env:TEMP "os-tmpl-nm-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + try { + Copy-Item -Force $PkgJson (Join-Path $WorkDir 'package.json') + $Lock = Join-Path $TmplFrontend 'package-lock.json' + Push-Location $WorkDir + if (Test-Path $Lock) { + Copy-Item -Force $Lock (Join-Path $WorkDir 'package-lock.json') + & npm ci --prefer-offline --no-audit --no-fund --loglevel=error + } else { + & npm install --prefer-offline --no-audit --no-fund --loglevel=error + } + if ($LASTEXITCODE -ne 0) { throw "npm install/ci failed ($LASTEXITCODE)" } + Pop-Location + $SrcNm = Join-Path $WorkDir 'node_modules' + if (-not (Test-Path $SrcNm)) { throw "no node_modules produced" } + New-Item -ItemType Directory -Force -Path $DestNm | Out-Null + # robocopy: fast, multi-threaded, handles the deep node_modules tree + long paths. + & robocopy $SrcNm $DestNm /E /NJH /NJS /NDL /NFL /NP /MT:8 | Out-Null + if ($LASTEXITCODE -ge 8) { throw "robocopy node_modules failed ($LASTEXITCODE)" } + $global:LASTEXITCODE = 0 + $Count = (Get-ChildItem -Recurse -File $DestNm -ErrorAction SilentlyContinue | Measure-Object).Count + Write-Host "[4b] pre-extracted node_modules staged at webapp_template_cache\$Digest ($Count files)" + } finally { + if ((Get-Location).Path -eq $WorkDir) { Pop-Location } + if (Test-Path $WorkDir) { Remove-Item -Recurse -Force $WorkDir } + } +} catch { + Write-Warning "[4b] pre-extract node_modules FAILED: $_ (App Builder first-app falls back to live npm; non-fatal)" +} # data: backend/config/paths.py points DATA_ROOT at %APPDATA%/OpenSwarm/data in # packaged mode and no code seeds from the bundle, so the entire shipped # backend/data/ tree was dead weight (and was leaking the dev machine's From 8df4d3821b5121d8d7a5aa1a85f849b738588b23 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:59:16 -0700 Subject: [PATCH 044/174] [eric] perf: draft stdlib-zip tool to shrink defender cold-start surface (#9 item 1) - standalone, dry-run by default, NOT wired into the release build (build-gated) - collapses ~910 loose stdlib files into python313.zip (cpython auto-adds it to sys.path) - validate on a packaged exe before enabling; see docs/perf/winv2 --- scripts/zip-python-stdlib.ps1 | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 scripts/zip-python-stdlib.ps1 diff --git a/scripts/zip-python-stdlib.ps1 b/scripts/zip-python-stdlib.ps1 new file mode 100644 index 00000000..000b6668 --- /dev/null +++ b/scripts/zip-python-stdlib.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + #9 item 1 (DRAFT, build-gated): collapse the bundled Python stdlib into a single + python313.zip so Windows Defender scans one file instead of hundreds of loose + .py/.pyc on every cold launch after an update (the 54-138s cold-start spikes). + +.WHY IT WORKS + CPython always puts "\python313.zip" on sys.path automatically (the zip + import path), so placing the stdlib there needs NO python._pth. We keep + Lib\site-packages and DLLs\ loose (native .pyd can't be imported from a zip), + and keep a small keep-list of stdlib dirs that read data files via __file__. + +.STATUS + UNVALIDATED. Default is -DryRun (reports only, changes nothing). Run -Apply on a + throwaway python-env copy, then boot the packaged backend and confirm every + import works + measure cold start (Task #10) BEFORE wiring this into a release. + It is intentionally NOT called by build-app-win.ps1 yet. + +.USAGE + pwsh scripts\zip-python-stdlib.ps1 -PythonEnv electron\python-env # dry run + pwsh scripts\zip-python-stdlib.ps1 -PythonEnv \python-env -Apply # perform +#> +param( + [Parameter(Mandatory = $true)][string]$PythonEnv, + [switch]$Apply +) + +$ErrorActionPreference = 'Stop' +$Lib = Join-Path $PythonEnv 'Lib' +$SitePkgs = Join-Path $Lib 'site-packages' +$ZipPath = Join-Path $PythonEnv 'python313.zip' + +if (-not (Test-Path $Lib)) { throw "no Lib\ under $PythonEnv" } + +# Stdlib dirs known to read data/grammar files relative to __file__ -> keep loose +# (zipimport gives them no real path). Conservative; expand if validation flags more. +$KeepLoose = @('site-packages', 'lib2to3', 'idlelib', 'tkinter', 'turtledemo', 'ensurepip', 'venv', 'test', '__pycache__') + +# Pure-stdlib set = everything directly under Lib\ EXCEPT the keep-list. Native +# stdlib extensions live in DLLs\ (not Lib\) on Windows, so Lib-minus-keeplist is +# pure python and safe to zip. +$entries = Get-ChildItem -Force $Lib | Where-Object { $KeepLoose -notcontains $_.Name } +$pyFiles = $entries | ForEach-Object { + if ($_.PSIsContainer) { Get-ChildItem -Recurse -File $_.FullName -Include *.py, *.pyc -ErrorAction SilentlyContinue } + elseif ($_.Extension -in '.py', '.pyc') { $_ } +} +$count = ($pyFiles | Measure-Object).Count +$bytes = ($pyFiles | Measure-Object -Property Length -Sum).Sum +Write-Host ("#9 item 1: {0} stdlib .py/.pyc files ({1:N1} MB) would be zipped into python313.zip" -f $count, ($bytes / 1MB)) +Write-Host ("keep-loose dirs: {0}" -f ($KeepLoose -join ', ')) + +if (-not $Apply) { + Write-Host "DRY RUN. Re-run with -Apply on a COPY of python-env, then validate (Task #10):" + Write-Host " 1. python.exe -c 'import backend.main' (full import tree resolves)" + Write-Host " 2. python.exe -X importtime -c 'import backend.main' parity vs loose" + Write-Host " 3. boot the packaged backend, exercise agents/app-builder/skills" + Write-Host " 4. measure cold backend-http-ready vs baseline_startup.csv" + return +} + +# --- Apply: build the zip, then remove the now-redundant loose copies. --- +if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath } +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::Open($ZipPath, 'Create') +try { + foreach ($f in $pyFiles) { + # Archive entry path must be relative to Lib\ so it resolves as a top-level + # module (e.g. Lib\json\__init__.py -> json/__init__.py in the zip root). + $rel = $f.FullName.Substring($Lib.Length + 1).Replace('\', '/') + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $f.FullName, $rel) | Out-Null + } +} finally { + $zip.Dispose() +} +Write-Host ("Wrote {0} ({1:N1} MB)" -f $ZipPath, ((Get-Item $ZipPath).Length / 1MB)) + +# Remove the loose stdlib we just zipped (keep the keep-list dirs untouched). +foreach ($e in $entries) { + if ($e.PSIsContainer) { Remove-Item -Recurse -Force $e.FullName } + elseif ($e.Extension -in '.py', '.pyc') { Remove-Item -Force $e.FullName } +} +Write-Host "Removed loose stdlib copies. VALIDATE on the packaged EXE before shipping (this is unvalidated)." From 17e6c06d5c03545b67d6b7e9efd71c55c56867d2 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 22:07:40 -0700 Subject: [PATCH 045/174] [eric] release: bump version to 1.3.86 - winv2: windows startup -90% warm, app builder windows fixes, skills seed --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index 6ea396a5..96597b02 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.85", + "version": "1.3.86", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From 177d2b6fb7abd314179d64e6d1eeb8d9b6f06c3c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 22:12:56 -0700 Subject: [PATCH 046/174] [eric] free-trial: warm connect-your-own-model banner when the trial is spent (was the red no-model wall) --- .../src/app/components/Layout/AppShell.tsx | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 6064e4df..f95b132a 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -23,7 +23,7 @@ import { LayoutGrid } from 'lucide-react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Settings as LucideSettings } from 'lucide-react'; import { Palette } from 'lucide-react'; -import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Plus, Sparkles } from 'lucide-react'; import { AnimatedPanelLeft } from './animatedIcons'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; @@ -151,6 +151,12 @@ const AppShell: React.FC = () => { const d = s.settings.data as any; return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token); }); + // Trial just ran dry (had an allotment, now 0, off the free lane): nudge a connect, but warmly, + // an upsell after the win, never the red error wall. Runs refill, so frame it as "for now". + const freeTrialSpent = useAppSelector((s) => { + const d = s.settings.data as any; + return !!(d && (d.free_trial_runs_limit ?? 0) > 0 && d.free_trial_remaining === 0 && d.connection_mode !== 'free-trial'); + }); // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it // flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.) const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled); @@ -512,8 +518,8 @@ const AppShell: React.FC = () => { gap: 1.5, px: 2, py: 0.6, - bgcolor: 'rgba(239, 68, 68, 0.08)', - borderBottom: '1px solid rgba(239, 68, 68, 0.18)', + bgcolor: isOnline && freeTrialSpent ? `${c.accent.primary}14` : 'rgba(239, 68, 68, 0.08)', + borderBottom: isOnline && freeTrialSpent ? `1px solid ${c.accent.primary}30` : '1px solid rgba(239, 68, 68, 0.18)', flexShrink: 0, animation: showWarningBanner ? 'warning-fade-in 0.4s ease-out' : undefined, '@keyframes warning-fade-in': { @@ -522,10 +528,32 @@ const AppShell: React.FC = () => { }, }} > - - + {isOnline && freeTrialSpent + ? + : } + {!isOnline ? 'No internet connection; agents cannot reach AI models or external services' + : freeTrialSpent + ? ( + <> + Your free runs are used up for now.{' '} + dispatch(openSettingsModal('models'))} + sx={{ + textDecoration: 'underline', + cursor: 'pointer', + fontWeight: 600, + '&:hover': { opacity: 0.8 }, + transition: 'opacity 0.15s', + }} + > + Connect your own Claude, ChatGPT, or Gemini + + {' '}to keep going. + + ) : ( <> No AI model connected.{' '} From dcd4f6f149bdc17b8b30ec55a4f13f6f6e24d311 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 22:15:45 -0700 Subject: [PATCH 047/174] [eric] perf: draft pyc-only site-packages strip to shrink defender cold-start (#9 item 3) - standalone, dry-run by default, NOT wired into the release build (build-gated) - strips 3352 .py (26.9MB) from site-packages to sourceless .pyc; mechanism proven on bundled 3.13 - scope: site-packages only (keeps backend source for the debugger); validate on a packaged exe --- docs/perf/winv2/README.md | 2 +- scripts/strip-py-to-pyc.ps1 | 80 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 scripts/strip-py-to-pyc.ps1 diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index d74e3f3f..4d38b39c 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -161,7 +161,7 @@ validated on a real packaged EXE (Task #10). 1. [DRAFTED, build-gated] Zip the Python stdlib -> python313.zip (medium risk). Draft: scripts/zip-python-stdlib.ps1 (dry-run by default; NOT wired into the release build yet). Measured on the real env: 910 stdlib .py/.pyc files (15.1 MB) collapse into one zip. CPython auto-adds /python313.zip to sys.path, so no python._pth is needed; site-packages + DLLs (native .pyd) stay loose; a keep-list keeps data-file stdlib dirs (lib2to3, idlelib, tkinter, ...) loose. Impact: ~7% of total python-env file count, but it collapses the stdlib import-time file-opens (the cold-launch Defender scan storm) into a single scanned file; bigger combined with #3. Validation (Task #10): -Apply on a copy, then import backend.main, importtime parity, boot the packaged backend, measure cold backend-http-ready vs baseline. Wire into build-app-win.ps1 behind an off-by-default -ZipStdlib switch only after it passes. 2. [DONE] Ship webapp_template node_modules PRE-EXTRACTED in resources + junction to it (kills the 14.2 s extract -> ~0 s). build-app-win.ps1 step 4b robocopies the tree into resources; runtime _bundled_extracted_modules()/_ensure_warm_cache() prefer it; tests in test_bundled_extracted_modules.py. Mac still ships the .tar.gz (unchanged). -3. Precompile + ship only .pyc (drop .py) for app + pure-python deps. Halves remaining loose-file count; low risk; stacks with #1. +3. [DRAFTED, build-gated] Ship site-packages as sourceless .pyc only (drop .py). Draft: scripts/strip-py-to-pyc.ps1 (dry-run default; NOT wired into the build). Measured: 3,352 .py (26.9 MB) + 362 __pycache__ dirs strippable from site-packages (keep-list excludes pip/setuptools). compileall -b writes legacy module.pyc next to source; we delete the .py whose .pyc exists and drop __pycache__. Sourceless import proven with the bundled 3.13 interpreter. Scope: site-packages ONLY (NOT backend app code -- the swarm-debug debugger reads our own source for frame annotation). .pyc magic must match the shipped interpreter, so compile with the bundled python. Validate on a packaged EXE (Task #10); some packages use inspect.getsource and may need the keep-list. Combined with #1 + #2 this takes python-env from ~13,554 files toward ~9,300 (~31% fewer for Defender). 4. Inventory + trim app.asar (639 MB): source maps, dev-only deps, duplicate bundles. Single file (not a count issue) but shrinks cold-read I/O. 5. Opt-in Defender exclusion for install/data dirs, documented, never silent (needs admin/UAC; security-sensitive). Settings toggle only; do not auto-apply. diff --git a/scripts/strip-py-to-pyc.ps1 b/scripts/strip-py-to-pyc.ps1 new file mode 100644 index 00000000..083eef86 --- /dev/null +++ b/scripts/strip-py-to-pyc.ps1 @@ -0,0 +1,80 @@ +<# +.SYNOPSIS + #9 item 3 (DRAFT, build-gated): ship site-packages as sourceless .pyc only, so + Windows Defender has ~half as many loose files to scan on a cold launch after + an update. Compiles each module.py -> legacy module.pyc (next to the source, + NOT in __pycache__), then deletes the .py whose .pyc exists and removes the + redundant __pycache__ dirs. Python imports the sourceless .pyc directly. + +.SCOPE + TARGET SITE-PACKAGES ONLY by default. Do NOT strip the backend app code: the + swarm-debug debugger reads our own .py source for frame annotation, and we want + readable tracebacks for first-party code. Stdlib is handled by #9 item 1 + (zip-python-stdlib.ps1); this is the dependency tree. + +.STATUS + UNVALIDATED. Default is -DryRun (reports only). The .pyc magic must match the + SHIPPED interpreter, so compile with the bundled python (-PythonExe). Some + packages read their own source (inspect.getsource) and break sourceless; keep + a keep-list and validate on a packaged EXE (Task #10) BEFORE wiring into a + release. Intentionally NOT called by build-app-win.ps1 yet. + +.USAGE + pwsh scripts\strip-py-to-pyc.ps1 -TargetDir electron\python-env\Lib\site-packages # dry run + pwsh scripts\strip-py-to-pyc.ps1 -TargetDir \site-packages -PythonExe \python.exe -Apply +#> +param( + [Parameter(Mandatory = $true)][string]$TargetDir, + [string]$PythonExe, + [switch]$Apply +) + +$ErrorActionPreference = 'Stop' +if (-not (Test-Path $TargetDir)) { throw "no target dir: $TargetDir" } + +# Packages that read their own .py at runtime (inspect.getsource / exec of source +# / .py-relative data) -> keep their source. Conservative starting set; expand +# whatever validation flags. Matched against the top-level package dir name. +$KeepSource = @('pip', 'setuptools', 'pkg_resources', '_distutils_hack') + +$allPy = Get-ChildItem -Recurse -File $TargetDir -Filter *.py -ErrorAction SilentlyContinue +$py = $allPy | Where-Object { + $rel = $_.FullName.Substring($TargetDir.Length).TrimStart('\', '/') + $top = ($rel -split '[\\/]')[0] + $KeepSource -notcontains $top +} +$pyCount = ($py | Measure-Object).Count +$pyMB = [math]::Round((($py | Measure-Object -Property Length -Sum).Sum) / 1MB, 1) +$pycacheDirs = (Get-ChildItem -Recurse -Directory $TargetDir -Filter __pycache__ -ErrorAction SilentlyContinue | Measure-Object).Count +Write-Host ("#9 item 3: {0} .py files ({1} MB) eligible under {2}" -f $pyCount, $pyMB, $TargetDir) +Write-Host ("keep-source packages: {0} | __pycache__ dirs present: {1}" -f ($KeepSource -join ', '), $pycacheDirs) + +if (-not $Apply) { + Write-Host "DRY RUN. -Apply compiles to legacy .pyc (compileall -b) next to each source," + Write-Host "deletes each .py whose .pyc now exists, and removes __pycache__. Validate (Task #10):" + Write-Host " 1. python.exe -c 'import backend.main' resolves (deps import sourceless)" + Write-Host " 2. boot the packaged backend; exercise agents/app-builder/skills/MCP" + Write-Host " 3. measure cold backend-http-ready vs baseline_startup.csv" + return +} + +if (-not $PythonExe) { throw "-PythonExe is required for -Apply (must be the SHIPPED interpreter; .pyc magic must match)" } +if (-not (Test-Path $PythonExe)) { throw "no python at $PythonExe" } + +# 1. Compile to legacy sourceless .pyc next to each source (-b). -q quiet; it +# continues past files that fail to compile (py2-only, optional) -> those keep +# their .py since no sibling .pyc is produced. +& $PythonExe -m compileall -b -q $TargetDir +# compileall returns nonzero if ANY file failed; that is expected for odd files, +# so we don't treat it as fatal -- we only delete .py that actually got a .pyc. +$global:LASTEXITCODE = 0 + +# 2. Delete each eligible .py that now has a sibling .pyc. +$deleted = 0 +foreach ($f in $py) { + $pyc = [System.IO.Path]::ChangeExtension($f.FullName, '.pyc') + if (Test-Path $pyc) { Remove-Item -Force $f.FullName; $deleted++ } +} +# 3. Remove redundant __pycache__ (we use the legacy .pyc next to source). +Get-ChildItem -Recurse -Directory $TargetDir -Filter __pycache__ -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force +Write-Host ("Removed {0} .py (kept {1} that did not compile). UNVALIDATED -- verify on the packaged EXE before shipping." -f $deleted, ($pyCount - $deleted)) From 8bc899ce749a6959fe59ab42a699547f7ccb211d Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 22:20:17 -0700 Subject: [PATCH 048/174] [eric] perf: exclude duplicated python-env + build-staging from the asar (#9 item 4) - inventory: 607MB asar was ~605MB duplication (python-env 408MB + build-staging 197MB) - both already ship unpacked in resources/; runtime reads resources/, never the asar - add build.files exclusion -> asar ~607MB -> ~2MB; kills the 639MB cold-read on first launch - add inspect_asar.js inventory tool; validate asar size + boot on a packaged exe --- docs/perf/winv2/README.md | 2 +- docs/perf/winv2/inspect_asar.js | 57 +++++++++++++++++++++++++++++++++ electron/package.json | 7 ++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 docs/perf/winv2/inspect_asar.js diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 4d38b39c..3de7d94c 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -162,7 +162,7 @@ validated on a real packaged EXE (Task #10). 1. [DRAFTED, build-gated] Zip the Python stdlib -> python313.zip (medium risk). Draft: scripts/zip-python-stdlib.ps1 (dry-run by default; NOT wired into the release build yet). Measured on the real env: 910 stdlib .py/.pyc files (15.1 MB) collapse into one zip. CPython auto-adds /python313.zip to sys.path, so no python._pth is needed; site-packages + DLLs (native .pyd) stay loose; a keep-list keeps data-file stdlib dirs (lib2to3, idlelib, tkinter, ...) loose. Impact: ~7% of total python-env file count, but it collapses the stdlib import-time file-opens (the cold-launch Defender scan storm) into a single scanned file; bigger combined with #3. Validation (Task #10): -Apply on a copy, then import backend.main, importtime parity, boot the packaged backend, measure cold backend-http-ready vs baseline. Wire into build-app-win.ps1 behind an off-by-default -ZipStdlib switch only after it passes. 2. [DONE] Ship webapp_template node_modules PRE-EXTRACTED in resources + junction to it (kills the 14.2 s extract -> ~0 s). build-app-win.ps1 step 4b robocopies the tree into resources; runtime _bundled_extracted_modules()/_ensure_warm_cache() prefer it; tests in test_bundled_extracted_modules.py. Mac still ships the .tar.gz (unchanged). 3. [DRAFTED, build-gated] Ship site-packages as sourceless .pyc only (drop .py). Draft: scripts/strip-py-to-pyc.ps1 (dry-run default; NOT wired into the build). Measured: 3,352 .py (26.9 MB) + 362 __pycache__ dirs strippable from site-packages (keep-list excludes pip/setuptools). compileall -b writes legacy module.pyc next to source; we delete the .py whose .pyc exists and drop __pycache__. Sourceless import proven with the bundled 3.13 interpreter. Scope: site-packages ONLY (NOT backend app code -- the swarm-debug debugger reads our own source for frame annotation). .pyc magic must match the shipped interpreter, so compile with the bundled python. Validate on a packaged EXE (Task #10); some packages use inspect.getsource and may need the keep-list. Combined with #1 + #2 this takes python-env from ~13,554 files toward ~9,300 (~31% fewer for Defender). -4. Inventory + trim app.asar (639 MB): source maps, dev-only deps, duplicate bundles. Single file (not a count issue) but shrinks cold-read I/O. +4. [APPLIED, build-gated] Trim app.asar. Inventory (docs/perf/winv2/inspect_asar.js) found the 607 MB asar is almost entirely DUPLICATION: python-env (408 MB, incl. a 242 MB bundled claude.exe) and build-staging (197 MB: node.exe 67 MB, uv.exe 65 MB, mcp-bundles, frontend) are packed into the asar AND already shipped UNPACKED in resources/ via extraResources. The runtime reads from resources/ (confirmed: "Starting backend: ...resources\python-env\python.exe"), never from inside the asar. Source maps were a red herring (0.4 MB). Fix: added a build.files exclusion in electron/package.json ("!python-env/**", "!build-staging/**") so those trees no longer pack into the asar -> ~607 MB -> ~2 MB (just main.js/preload/node_modules). Removes the entire 639 MB cold-read on first launch. Validate on a packaged EXE (Task #10): app still boots (python/node/router resolved from resources), asar size shrunk. 5. Opt-in Defender exclusion for install/data dirs, documented, never silent (needs admin/UAC; security-sensitive). Settings toggle only; do not auto-apply. Recommended order: #2 (biggest UX win, lowest risk), then #1 (largest cold win, careful import testing), then #3/#4. Validation: re-run profile_startup.sh + a fresh-extract timing on the packaged EXE after each change, diff vs baseline_startup.csv. diff --git a/docs/perf/winv2/inspect_asar.js b/docs/perf/winv2/inspect_asar.js new file mode 100644 index 00000000..3a3e19ed --- /dev/null +++ b/docs/perf/winv2/inspect_asar.js @@ -0,0 +1,57 @@ +// #9 item 4: inventory an app.asar without extracting it. Parses the asar header +// (a Chromium Pickle: [u32 payloadSize][u32 headerSize] then [u32 payloadSize] +// [u32 jsonLen][json...]) and reports total size, biggest top-level dirs, biggest +// individual files, and trimmable categories (source maps, etc.). +// Usage: node inspect_asar.js +const fs = require('fs'); + +const asar = process.argv[2]; +if (!asar) { console.error('usage: node inspect_asar.js '); process.exit(1); } + +const fd = fs.openSync(asar, 'r'); +const head = Buffer.alloc(8); +fs.readSync(fd, head, 0, 8, 0); +const headerSize = head.readUInt32LE(4); // size of the header pickle +const hp = Buffer.alloc(headerSize); +fs.readSync(fd, hp, 0, headerSize, 8); +const jsonLen = hp.readUInt32LE(4); // string length inside the pickle +const json = hp.slice(8, 8 + jsonLen).toString('utf8'); +const header = JSON.parse(json); +fs.closeSync(fd); + +let total = 0, fileCount = 0; +const byExt = {}; +const files = []; // {path, size} +const topDirs = {}; // top-level entry -> size + +function walk(node, parts) { + if (node.files) { + for (const [name, child] of Object.entries(node.files)) walk(child, parts.concat(name)); + } else if (typeof node.size === 'number') { + const p = parts.join('/'); + total += node.size; fileCount++; + files.push({ p, size: node.size }); + const ext = (p.match(/\.[^./]+$/) || ['(none)'])[0].toLowerCase(); + byExt[ext] = (byExt[ext] || 0) + node.size; + topDirs[parts[0]] = (topDirs[parts[0]] || 0) + node.size; + } +} +walk(header, []); + +const mb = (b) => (b / 1048576).toFixed(1) + ' MB'; +const sortObj = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]); + +console.log(`asar total: ${mb(total)} across ${fileCount} files\n`); +console.log('=== biggest top-level entries ==='); +for (const [d, s] of sortObj(topDirs).slice(0, 15)) console.log(` ${mb(s).padStart(10)} ${d}`); +console.log('\n=== biggest single files ==='); +for (const f of files.sort((a, b) => b.size - a.size).slice(0, 20)) console.log(` ${mb(f.size).padStart(10)} ${f.p}`); +console.log('\n=== by extension (top 15) ==='); +for (const [e, s] of sortObj(byExt).slice(0, 15)) console.log(` ${mb(s).padStart(10)} ${e}`); +console.log('\n=== trimmable categories ==='); +const cat = (re) => files.filter(f => re.test(f.p)).reduce((n, f) => n + f.size, 0); +console.log(` source maps (*.map): ${mb(cat(/\.map$/))}`); +console.log(` .ts/.tsx sources: ${mb(cat(/\.tsx?$/))}`); +console.log(` markdown/license/readme: ${mb(cat(/(\.md|license|readme|changelog)/i))}`); +console.log(` test/spec/__tests__: ${mb(cat(/(\/test\/|\/tests\/|__tests__|\.spec\.|\.test\.)/i))}`); +console.log(` node_modules inside asar: ${mb(cat(/(^|\/)node_modules\//))}`); diff --git a/electron/package.json b/electron/package.json index 96597b02..15d07207 100644 --- a/electron/package.json +++ b/electron/package.json @@ -41,6 +41,13 @@ "directories": { "output": "dist" }, + "files": [ + "**/*", + "!python-env", + "!python-env/**", + "!build-staging", + "!build-staging/**" + ], "icon": "build/icon.png", "mac": { "icon": "build/icon.icns", From b8dc125da75665b3e565c350ba2633547996abbd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 22:23:02 -0700 Subject: [PATCH 049/174] [eric] phase2: suggest-only offer_for_gated_server core + gate-safety invariants (in-task MCP connect) --- backend/apps/agents/core/mcp_preflight.py | 14 +++++ backend/tests/test_mcp_offer.py | 64 +++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 backend/tests/test_mcp_offer.py diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py index fad2c739..5db88d3c 100644 --- a/backend/apps/agents/core/mcp_preflight.py +++ b/backend/apps/agents/core/mcp_preflight.py @@ -138,6 +138,20 @@ def _build_available_shortlist(settings) -> list[CuratedEntry]: ] +def offer_for_gated_server(server_name: str, settings) -> CuratedEntry | None: + """Mid-run a running agent may reach for a vetted MCP it isn't granted; this maps that + server to a one-click connect offer to SHOW the user. Suggest-only by construction: it + returns data to display, never an action that grants access, so it cannot widen the MCP + surface (activation stays behind MCPActivate + the dispatch gate). Returns None unless the + server is vetted AND inactive AND not dismissed, reusing the same filter as the preflight.""" + if not server_name or not isinstance(server_name, str): + return None + entry = next((e for e in _build_available_shortlist(settings) if e["id"] == server_name), None) + if entry is None: + return None + return {"id": entry["id"], "title": entry["title"], "description": entry["description"], "reason": ""} + + def _decorate(llm_suggestion: dict, available: list[CuratedEntry]) -> dict | None: """Expand an LLM-returned {id, reason} into the full frontend shape.""" entry = next((e for e in available if e["id"] == llm_suggestion["id"]), None) diff --git a/backend/tests/test_mcp_offer.py b/backend/tests/test_mcp_offer.py new file mode 100644 index 00000000..c494d9a5 --- /dev/null +++ b/backend/tests/test_mcp_offer.py @@ -0,0 +1,64 @@ +"""Gate-safety invariants for the Phase 2 in-task connect offer (offer_for_gated_server). + +The whole point of the offer is that it can ONLY ever suggest, never grant: it must surface a +vetted, inactive, not-dismissed MCP for the user to one-click-connect, and it must never carry +anything that could widen the MCP surface on its own. These tests make a bad offer state fail +loudly instead of shipping a silent gate bypass. +""" + +from types import SimpleNamespace + +import backend.apps.agents.core.mcp_preflight as pf +from backend.apps.agents.core.mcp_preflight import ( + CURATED_SHORTLIST, + offer_for_gated_server, +) + +VETTED = {e["id"] for e in CURATED_SHORTLIST} +OFFER_SHAPE = {"id", "title", "description", "reason"} + + +def _settings(dismissed=None): + return SimpleNamespace(dismissed_mcp_suggestions=dismissed or {}) + + +def test_offer_only_returns_vetted_inactive(monkeypatch): + monkeypatch.setattr(pf, "load_all_tools", lambda: []) # nothing enabled + s = _settings() + o = offer_for_gated_server("Google Workspace", s) + assert o is not None + assert o["id"] == "Google Workspace" + assert o["id"] in VETTED + + +def test_offer_rejects_unvetted_and_empty(monkeypatch): + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + s = _settings() + assert offer_for_gated_server("NotAVettedServer", s) is None + assert offer_for_gated_server("", s) is None + assert offer_for_gated_server(None, s) is None # type: ignore[arg-type] + + +def test_offer_suppressed_when_dismissed(monkeypatch): + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + s = _settings({"Google Workspace": "2026-01-01T00:00:00Z"}) + assert offer_for_gated_server("Google Workspace", s) is None + + +def test_offer_suppressed_when_already_active(monkeypatch): + monkeypatch.setattr( + pf, "load_all_tools", + lambda: [SimpleNamespace(name="Google Workspace", enabled=True)], + ) + s = _settings() + assert offer_for_gated_server("Google Workspace", s) is None + + +def test_offer_carries_no_activate_capability(monkeypatch): + # The security invariant: an offer is data to display, never an action that grants access. + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + s = _settings() + for entry in CURATED_SHORTLIST: + o = offer_for_gated_server(entry["id"], s) + assert o is not None + assert set(o.keys()) == OFFER_SHAPE, f"offer for {entry['id']} grew an unexpected field" From 947a6c1db5a0bf0e45d849593b2684158dac3462 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 22:23:11 -0700 Subject: [PATCH 050/174] [eric] perf: draft opt-in defender exclusion helper for windows cold-start (#9 item 5) - scripts/add-defender-exclusion.ps1: dry-run default; -Apply/-Remove (admin); -Status - excludes %LOCALAPPDATA%/%APPDATA%/~ openswarm dirs; kills defender cold-scan entirely - security-sensitive: opt-in only, never silent; settings-toggle design documented, not auto-applied --- docs/perf/winv2/README.md | 2 +- scripts/add-defender-exclusion.ps1 | 73 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 scripts/add-defender-exclusion.ps1 diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 3de7d94c..38b39663 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -163,7 +163,7 @@ validated on a real packaged EXE (Task #10). 2. [DONE] Ship webapp_template node_modules PRE-EXTRACTED in resources + junction to it (kills the 14.2 s extract -> ~0 s). build-app-win.ps1 step 4b robocopies the tree into resources; runtime _bundled_extracted_modules()/_ensure_warm_cache() prefer it; tests in test_bundled_extracted_modules.py. Mac still ships the .tar.gz (unchanged). 3. [DRAFTED, build-gated] Ship site-packages as sourceless .pyc only (drop .py). Draft: scripts/strip-py-to-pyc.ps1 (dry-run default; NOT wired into the build). Measured: 3,352 .py (26.9 MB) + 362 __pycache__ dirs strippable from site-packages (keep-list excludes pip/setuptools). compileall -b writes legacy module.pyc next to source; we delete the .py whose .pyc exists and drop __pycache__. Sourceless import proven with the bundled 3.13 interpreter. Scope: site-packages ONLY (NOT backend app code -- the swarm-debug debugger reads our own source for frame annotation). .pyc magic must match the shipped interpreter, so compile with the bundled python. Validate on a packaged EXE (Task #10); some packages use inspect.getsource and may need the keep-list. Combined with #1 + #2 this takes python-env from ~13,554 files toward ~9,300 (~31% fewer for Defender). 4. [APPLIED, build-gated] Trim app.asar. Inventory (docs/perf/winv2/inspect_asar.js) found the 607 MB asar is almost entirely DUPLICATION: python-env (408 MB, incl. a 242 MB bundled claude.exe) and build-staging (197 MB: node.exe 67 MB, uv.exe 65 MB, mcp-bundles, frontend) are packed into the asar AND already shipped UNPACKED in resources/ via extraResources. The runtime reads from resources/ (confirmed: "Starting backend: ...resources\python-env\python.exe"), never from inside the asar. Source maps were a red herring (0.4 MB). Fix: added a build.files exclusion in electron/package.json ("!python-env/**", "!build-staging/**") so those trees no longer pack into the asar -> ~607 MB -> ~2 MB (just main.js/preload/node_modules). Removes the entire 639 MB cold-read on first launch. Validate on a packaged EXE (Task #10): app still boots (python/node/router resolved from resources), asar size shrunk. -5. Opt-in Defender exclusion for install/data dirs, documented, never silent (needs admin/UAC; security-sensitive). Settings toggle only; do not auto-apply. +5. [DRAFTED, opt-in] Defender exclusion for OpenSwarm's dirs -- the nuclear cold-start fix (stops real-time scanning entirely, so it kills BOTH the 54-138s post-update launch and the ~14s extract). Draft: scripts/add-defender-exclusion.ps1 (dry-run by default; -Apply/-Remove need admin; -Status lists). Excludes %LOCALAPPDATA%\openswarm, %APPDATA%\openswarm, ~/.openswarm (verified the paths resolve). SECURITY: reduces AV coverage of those folders, so it must ALWAYS be an explicit user choice -- never auto-run, never a startup prompt. Proposed surface: an OFF-by-default Settings > Advanced toggle ("Faster Windows startup -- adds a Defender exclusion for OpenSwarm; one-time admin approval; reversible"), which on enable spawns an elevated `powershell Start-Process -Verb RunAs` to run the script -Apply (UAC), and -Remove on disable. This is a passive opt-in toggle, NOT a banner/tip/prompt, so it respects the no-user-action-UI rule. Not wired into the frontend yet (design only). Recommended order: #2 (biggest UX win, lowest risk), then #1 (largest cold win, careful import testing), then #3/#4. Validation: re-run profile_startup.sh + a fresh-extract timing on the packaged EXE after each change, diff vs baseline_startup.csv. diff --git a/scripts/add-defender-exclusion.ps1 b/scripts/add-defender-exclusion.ps1 new file mode 100644 index 00000000..4537c739 --- /dev/null +++ b/scripts/add-defender-exclusion.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS + #9 item 5 (DRAFT, opt-in, NEVER silent): add a Windows Defender exclusion for + OpenSwarm's install + data dirs. This is the nuclear cold-start fix -- it stops + Defender real-time-scanning those folders entirely, which is the root of the + 54-138s post-update cold launch AND the ~14s first-app extract. + +.SECURITY + Excluding a folder from Defender reduces AV coverage of it. This must ALWAYS be + an explicit, informed user choice -- never auto-run, never a startup prompt. The + install is Azure code-signed, so the risk is bounded, but the user owns the + call. Fully reversible with -Remove. Requires admin (Add/Remove-MpPreference do). + +.USAGE + pwsh scripts\add-defender-exclusion.ps1 # show plan + paths, change nothing + pwsh scripts\add-defender-exclusion.ps1 -Status # list current openswarm exclusions + pwsh scripts\add-defender-exclusion.ps1 -Apply # add (run elevated) + pwsh scripts\add-defender-exclusion.ps1 -Remove # undo (run elevated) +#> +param( + [switch]$Apply, + [switch]$Remove, + [switch]$Status +) + +$ErrorActionPreference = 'Stop' + +# The three trees Defender rescans on launch / first-app: the Squirrel install +# (executables + python-env + node_modules), the Electron user data, and the +# warm caches. +$paths = @( + (Join-Path $env:LOCALAPPDATA 'openswarm'), + (Join-Path $env:APPDATA 'openswarm'), + (Join-Path $env:USERPROFILE '.openswarm') +) | Where-Object { $_ } + +function Test-Admin { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal $id).IsInRole( + [Security.Principal.WindowsBuiltinRole]::Administrator) +} + +if ($Status) { + try { + $ex = (Get-MpPreference).ExclusionPath | Where-Object { $_ -match 'openswarm' } + if ($ex) { $ex | ForEach-Object { Write-Host " excluded: $_" } } else { Write-Host " (no openswarm Defender exclusions set)" } + } catch { + Write-Warning "Defender not queryable here (non-Defender AV, or needs elevation): $_" + } + return +} + +Write-Host "OpenSwarm Defender exclusion (OPT-IN). Would apply to:" +$paths | ForEach-Object { Write-Host " $_" } +Write-Host "" +Write-Host "SECURITY: this stops Windows Defender from real-time-scanning those folders." +Write-Host "Only do this if you trust this install (it is code-signed). Reversible with -Remove." + +if (-not ($Apply -or $Remove)) { + Write-Host "" + Write-Host "DRY RUN -- nothing changed. Re-run ELEVATED with -Apply (add), -Remove (undo), or -Status (list)." + return +} + +if (-not (Test-Admin)) { + throw "Needs admin. Re-run from an elevated PowerShell (Add/Remove-MpPreference require elevation)." +} + +foreach ($p in $paths) { + if ($Apply) { Add-MpPreference -ExclusionPath $p; Write-Host "added exclusion: $p" } + else { Remove-MpPreference -ExclusionPath $p; Write-Host "removed exclusion: $p" } +} +Write-Host "Done. Verify with -Status." From 58c2b6f4110e01fe2aa75deff6951b0b219107ec Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 22:29:55 -0700 Subject: [PATCH 051/174] [eric] perf: task #10 validation kit for the signed packaged build - TASK10_CHECKLIST.md: produce signed build, verify signature, install, cold-start, GUI checks - validate_packaged.ps1: automated structural + perf checks (verified it flags the unfixed 1.2.82) --- docs/perf/winv2/TASK10_CHECKLIST.md | 55 ++++++++++++++++++++++++ docs/perf/winv2/validate_packaged.ps1 | 62 +++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/perf/winv2/TASK10_CHECKLIST.md create mode 100644 docs/perf/winv2/validate_packaged.ps1 diff --git a/docs/perf/winv2/TASK10_CHECKLIST.md b/docs/perf/winv2/TASK10_CHECKLIST.md new file mode 100644 index 00000000..dfa38e04 --- /dev/null +++ b/docs/perf/winv2/TASK10_CHECKLIST.md @@ -0,0 +1,55 @@ +# Task #10 โ€” validate the winv2 changes on the REAL signed Windows build + +Do not call winv2 "done" until a **code-signed, downloaded, installed, launched** +build passes this. Unit tests + dry-runs are necessary but not sufficient โ€” the +build-script changes (asar exclusion, node_modules pre-extract) and the cold-start +wins only exist in a packaged EXE. + +## 0. Produce the signed build +- Tag the winv2 HEAD and push: `git tag v1.3.86 && git push origin v1.3.86`. +- This runs `.github/workflows/release-windows.yml` (Azure code-signing) and creates + a **draft** GitHub release (drafts do NOT auto-update existing users). +- Watch it: `gh run watch` / `gh run list --workflow=release-windows.yml`. +- If `build-app-win.ps1` errors on the new step 4b (pre-extract) or the asar + `files` exclusion, fix and re-tag (delete the draft + tag first; never force-push + an existing release tag). + +## 1. Download + verify the signature (must be real signed bits) +- `gh release download v1.3.86 --pattern "*Setup*.exe" --dir .` (or from the draft release page). +- Verify Authenticode: `Get-AuthenticodeSignature .\OpenSwarm-Setup-x64.exe` โ†’ Status must be `Valid`, signer = the Azure Trusted Signing cert. NOT "NotSigned"/"UnknownError". + +## 2. Install + first (COLD) launch โ€” the headline metric +- Install the downloaded EXE (Squirrel โ†’ `%LOCALAPPDATA%\openswarm`). +- Launch once and let it fully load. This is the COLD launch (Defender scans fresh files). +- Then run the automated checker: `pwsh docs/perf/winv2/validate_packaged.ps1`. +- Acceptance (perf): cold `backend-http-ready` should be **far below the 54-138s baseline** + (target: well under the 10s goal even cold, given the 639MB asar read is gone + + fewer files to scan). Relaunch once for the warm number (target ~2-3s). + +## 3. Automated structural checks (validate_packaged.ps1 must be all PASS) +- app.asar < 50 MB (was ~607 MB) โ€” #9 item 4. +- app.asar does NOT contain python-env / build-staging โ€” #9 item 4. +- `resources/python-env/python.exe` present (still shipped unpacked). +- `resources/node/x64/node.exe` present. +- `resources/backend/apps/skill_registry/skills_snapshot.json` present โ€” Bug #1. +- webapp_template_cache has a pre-extracted `/node_modules/vite/bin/vite.js` + (#9 item 2) โ€” or a `.tar.gz` fallback. + +## 4. Manual GUI checks (can't be automated) +- **Skills (Bug #1):** open the Skills page on a fresh launch โ†’ the catalog shows + immediately (NOT empty). Run onboarding step 6/8 "Install a skill" โ†’ it finds the + pdf skill (no `waitForSelector "skill-item-pdf" 15000ms` timeout). +- **App Builder (Bug #2):** create an app โ†’ the preview goes LIVE. No `[WinError 2]`, + no "backend exited with code 1". First app should be quick (pre-extracted nm + vite). + Bonus: test on a machine WITHOUT Git Bash to confirm the no-bash vite path. +- Sanity: send an agent message (9Router now starts in the background โ†’ first message + may wait a moment for it; confirm it still answers). + +## 5. Optional cold-start levers (only after 1-4 pass) +- #9 item 1 (`zip-python-stdlib.ps1 -Apply`) and item 3 (`strip-py-to-pyc.ps1 -Apply`) + on a build copy, then re-run 1-4 + re-measure. Enable in the build only if green. +- #9 item 5 (`add-defender-exclusion.ps1`) is a user opt-in, validate separately. + +## 6. Sign-off +- All of 1-4 green on the signed build โ†’ publish the draft release (un-draft) to ship 1.3.86. +- Record the real cold/warm numbers in `boot_breakdown.csv` / README "Results (AFTER)". diff --git a/docs/perf/winv2/validate_packaged.ps1 b/docs/perf/winv2/validate_packaged.ps1 new file mode 100644 index 00000000..b3d4b7a5 --- /dev/null +++ b/docs/perf/winv2/validate_packaged.ps1 @@ -0,0 +1,62 @@ +<# +.SYNOPSIS + Task #10 automated checks: run AFTER installing the signed build. Confirms the + winv2 structural fixes landed in the packaged app and reads the REAL cold/warm + backend-http-ready from the app's own perf log. Manual GUI checks are in + TASK10_CHECKLIST.md (App Builder preview, Skills list, onboarding). +.USAGE + pwsh docs\perf\winv2\validate_packaged.ps1 +#> +param( + [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'openswarm'), + [string]$BackendLog = (Join-Path $env:APPDATA 'openswarm\data\backend.log') +) +$ErrorActionPreference = 'Stop' +$pass = 0; $fail = 0 +function ok($m) { Write-Host " PASS $m" -ForegroundColor Green; $script:pass++ } +function bad($m) { Write-Host " FAIL $m" -ForegroundColor Red; $script:fail++ } +function info($m) { Write-Host " .. $m" -ForegroundColor DarkGray } + +$app = Get-ChildItem $InstallRoot -Directory -Filter 'app-*' -EA SilentlyContinue | Sort-Object Name | Select-Object -Last 1 +if (-not $app) { throw "no app-* under $InstallRoot (install the build first)" } +$res = Join-Path $app.FullName 'resources' +Write-Host "Validating packaged build: $res`n" + +# #9 item 4: asar trimmed +$asar = Join-Path $res 'app.asar' +if (Test-Path $asar) { + $asarMB = [math]::Round((Get-Item $asar).Length / 1MB, 1) + if ($asarMB -lt 50) { ok "app.asar = ${asarMB} MB (trimmed; was ~607 MB)" } else { bad "app.asar = ${asarMB} MB (expected < 50)" } + $insp = Join-Path $PSScriptRoot 'inspect_asar.js' + if ((Get-Command node -EA SilentlyContinue) -and (Test-Path $insp)) { + $out = & node $insp $asar 2>&1 | Out-String + if ($out -match 'python-env|build-staging') { bad "asar STILL contains python-env/build-staging" } else { ok "asar excludes python-env + build-staging" } + } +} else { bad "app.asar not found" } + +# still shipped unpacked (runtime reads these) +if (Test-Path (Join-Path $res 'python-env\python.exe')) { ok "python-env shipped unpacked" } else { bad "python-env\python.exe missing" } +if (Test-Path (Join-Path $res 'node\x64\node.exe')) { ok "node bundled" } else { bad "node\x64\node.exe missing" } + +# Bug #1: skills snapshot +if (Test-Path (Join-Path $res 'backend\apps\skill_registry\skills_snapshot.json')) { ok "skills snapshot shipped (catalog never empty)" } else { bad "skills_snapshot.json missing" } + +# #9 item 2 / Bug #2: webapp node_modules pre-extracted or archive +$cache = Join-Path $res 'backend\apps\outputs\webapp_template_cache' +if (Test-Path (Join-Path $cache '*\node_modules\vite\bin\vite.js')) { ok "webapp node_modules PRE-EXTRACTED (zero first-app extract)" } +elseif (Test-Path (Join-Path $cache 'node_modules.*.tar.gz')) { info "webapp node_modules shipped as .tar.gz (extract path, not pre-extracted)" } +else { bad "no webapp node_modules tree/archive in resources" } + +# perf: real cold/warm backend-http-ready from the app's own log +if (Test-Path $BackendLog) { + $m = Select-String -Path $BackendLog -Pattern 'backend-http-ready t=(\d+)' -AllMatches + $vals = @($m.Matches | ForEach-Object { [int]$_.Groups[1].Value }) + if ($vals.Count) { + $recent = ($vals | Select-Object -Last 6 | ForEach-Object { [math]::Round($_ / 1000, 1) }) -join 's, ' + info "backend-http-ready recent: ${recent}s (baseline: warm ~9-10s, cold 54-138s)" + info "latest: $([math]::Round($vals[-1]/1000,1))s -- first launch after install = COLD; relaunch for warm" + } else { info "no backend-http-ready markers yet" } +} else { info "no backend.log yet (launch the app once first)" } + +Write-Host "`n$pass passed, $fail failed. Manual GUI checks: TASK10_CHECKLIST.md (App Builder, Skills, onboarding)." +if ($fail) { exit 1 } From 78c0971e88b8db34e9c2867bcdb1a27f294e6475 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 22:34:28 -0700 Subject: [PATCH 052/174] [eric] phase2: wire in-task MCP connect offer into the ToolSearch loop-breaker (suggest-only, slug-matched) --- backend/apps/agents/agent_manager.py | 28 +++++++++++++++++++---- backend/apps/agents/core/mcp_preflight.py | 9 +++++++- backend/tests/test_mcp_offer.py | 13 +++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 74c8c3c2..30f91c4c 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -847,6 +847,9 @@ class AgentManager: # Counts ToolSearch calls in a row (no other tool between them). A run # of these with empty results is the "looping on ToolSearch" wedge. _ts_loop = {"n": 0} + # One mid-run connect offer per session: a stuck agent fires the loop-breaker repeatedly, + # but the user should see the "connect this MCP" card once, not on every retry. + _mcp_offer_sent = {"done": False} async def pre_tool_hook(input_data, tool_use_id, context): tool_name = input_data.get("tool_name", "") @@ -862,12 +865,29 @@ class AgentManager: if tool_name == "ToolSearch": _ts_loop["n"] += 1 if _ts_loop["n"] >= TOOLSEARCH_LOOP_THRESHOLD: - _reason = toolsearch_loop_redirect( - _ts_loop["n"], - self._gated_mcp_server_names(session.allowed_tools, session.active_mcps), - ) + _gated = self._gated_mcp_server_names(session.allowed_tools, session.active_mcps) + _reason = toolsearch_loop_redirect(_ts_loop["n"], _gated) if _reason: logger.info(f"[MCP-DEBUG] ToolSearch loop-breaker fired for {session_id} (n={_ts_loop['n']})") + # 2B-MCP: also surface a one-click connect offer to the USER for the vetted + # gated servers the agent keeps reaching for. Suggest-only: this just shows a + # card on the same channel the preflight uses; activation still requires + # MCPActivate + the dispatch gate, so it opens no side channel. Once per run, + # fail-open (an offer hiccup must never block the agent). + if not _mcp_offer_sent["done"]: + try: + from backend.apps.agents.core.mcp_preflight import offer_for_gated_server + _s = load_settings() + _offers = [o for o in (offer_for_gated_server(n, _s) for n in _gated) if o] + if _offers: + _mcp_offer_sent["done"] = True + await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", { + "session_id": session_id, + "suggestions": _offers, + "is_vague": False, + }) + except Exception: + logger.debug("mid-run MCP connect offer skipped", exc_info=True) return { "hookSpecificOutput": { "hookEventName": hook_event, diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py index 5db88d3c..cf5fbe16 100644 --- a/backend/apps/agents/core/mcp_preflight.py +++ b/backend/apps/agents/core/mcp_preflight.py @@ -12,6 +12,7 @@ from backend.apps.agents.providers.registry import resolve_aux_model from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.settings.settings import load_settings from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools +from backend.apps.tools_lib.mcp_config import _sanitize_server_name logger = logging.getLogger(__name__) @@ -146,7 +147,13 @@ def offer_for_gated_server(server_name: str, settings) -> CuratedEntry | None: server is vetted AND inactive AND not dismissed, reusing the same filter as the preflight.""" if not server_name or not isinstance(server_name, str): return None - entry = next((e for e in _build_available_shortlist(settings) if e["id"] == server_name), None) + # The hot-path hands us a sanitized slug ("google-workspace"); curated ids are display names + # ("Google Workspace"). Match on the slug of both sides so neither form is a load-bearing string. + slug = _sanitize_server_name(server_name) + entry = next( + (e for e in _build_available_shortlist(settings) if _sanitize_server_name(e["id"]) == slug), + None, + ) if entry is None: return None return {"id": entry["id"], "title": entry["title"], "description": entry["description"], "reason": ""} diff --git a/backend/tests/test_mcp_offer.py b/backend/tests/test_mcp_offer.py index c494d9a5..47684463 100644 --- a/backend/tests/test_mcp_offer.py +++ b/backend/tests/test_mcp_offer.py @@ -22,13 +22,16 @@ def _settings(dismissed=None): return SimpleNamespace(dismissed_mcp_suggestions=dismissed or {}) -def test_offer_only_returns_vetted_inactive(monkeypatch): +def test_offer_resolves_both_display_name_and_hotpath_slug(monkeypatch): + # The hot-path passes a sanitized slug ("google-workspace"); the curated id is a display + # name ("Google Workspace"). Both must resolve, so the wiring isn't a load-bearing string. monkeypatch.setattr(pf, "load_all_tools", lambda: []) # nothing enabled s = _settings() - o = offer_for_gated_server("Google Workspace", s) - assert o is not None - assert o["id"] == "Google Workspace" - assert o["id"] in VETTED + for name in ("Google Workspace", "google-workspace"): + o = offer_for_gated_server(name, s) + assert o is not None, f"{name!r} should resolve to the vetted entry" + assert o["id"] == "Google Workspace" + assert o["id"] in VETTED def test_offer_rejects_unvetted_and_empty(monkeypatch): From 623a81945738ad22b5b82597b6b29ee5a46a87d8 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 22:38:47 -0700 Subject: [PATCH 053/174] [eric] perf: task #10 download-speed + signature tracker for the signed build - times gh release download (MB/s), verifies authenticode, hands off to validate_packaged.ps1 --- docs/perf/winv2/measure_download_install.ps1 | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/perf/winv2/measure_download_install.ps1 diff --git a/docs/perf/winv2/measure_download_install.ps1 b/docs/perf/winv2/measure_download_install.ps1 new file mode 100644 index 00000000..6af651c2 --- /dev/null +++ b/docs/perf/winv2/measure_download_install.ps1 @@ -0,0 +1,35 @@ +<# +.SYNOPSIS + Task #10: track the user-facing DOWNLOAD speed + verify the signature of the + signed build, then hand off to validate_packaged.ps1 for install/startup. Does + NOT publish anything (downloads from the draft release / run artifacts only). +.USAGE + pwsh docs\perf\winv2\measure_download_install.ps1 -Tag v1.3.86 +#> +param( + [string]$Tag = 'v1.3.86', + [string]$WorkDir = (Join-Path $env:TEMP "os-dl-$Tag") +) +$ErrorActionPreference = 'Stop' +New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + +# 1. Download the signed installer (timed) -> download speed. +$sw = [Diagnostics.Stopwatch]::StartNew() +gh release download $Tag --pattern '*Setup*.exe' --dir $WorkDir --clobber +$sw.Stop() +$exe = Get-ChildItem $WorkDir -Filter '*Setup*.exe' | Select-Object -First 1 +if (-not $exe) { throw "no Setup .exe for $Tag (is the draft release built? try: gh run download )" } +$mb = [math]::Round($exe.Length / 1MB, 1) +$secs = [math]::Round($sw.Elapsed.TotalSeconds, 1) +$mbps = if ($secs -gt 0) { [math]::Round($mb / $secs, 1) } else { 'inf' } +Write-Host ("DOWNLOAD: {0} MB in {1}s ({2} MB/s) -> {3}" -f $mb, $secs, $mbps, $exe.Name) + +# 2. Verify it is really code-signed. +$sig = Get-AuthenticodeSignature $exe.FullName +Write-Host ("SIGNATURE: {0} signer={1}" -f $sig.Status, $sig.SignerCertificate.Subject) +if ($sig.Status -ne 'Valid') { Write-Warning "signature is NOT Valid -- stop and investigate before installing" } + +Write-Host "" +Write-Host "Installer: $($exe.FullName)" +Write-Host "Next (install timing): note the clock, run the installer, then time until %APPDATA%\openswarm\data\backend.log appears." +Write-Host "Then: pwsh docs\perf\winv2\validate_packaged.ps1 (structural + cold/warm startup)" From 920f73cee9283823f000c0d03615b88eb36e1d65 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 22:57:43 -0700 Subject: [PATCH 054/174] [eric] phase2/3: minimal borderless free-trial nudges (post-wow + spent) + minimal in-task MCP connect card --- .../src/app/components/Layout/AppShell.tsx | 78 +++++--- .../src/app/pages/AgentChat/AgentChat.tsx | 176 +++++++----------- 2 files changed, 115 insertions(+), 139 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index f95b132a..60f951d9 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -23,7 +23,7 @@ import { LayoutGrid } from 'lucide-react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Settings as LucideSettings } from 'lucide-react'; import { Palette } from 'lucide-react'; -import { ArrowLeft, ArrowRight, Plus, Sparkles } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'; import { AnimatedPanelLeft } from './animatedIcons'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; @@ -151,16 +151,30 @@ const AppShell: React.FC = () => { const d = s.settings.data as any; return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token); }); - // Trial just ran dry (had an allotment, now 0, off the free lane): nudge a connect, but warmly, - // an upsell after the win, never the red error wall. Runs refill, so frame it as "for now". + // Trial just ran dry (had an allotment, now 0, off the free lane): a quiet connect nudge, not the + // red error wall. Runs refill, so it's "for now". const freeTrialSpent = useAppSelector((s) => { const d = s.settings.data as any; return !!(d && (d.free_trial_runs_limit ?? 0) > 0 && d.free_trial_remaining === 0 && d.connection_mode !== 'free-trial'); }); + // Post-wow: on the free lane and already got value (spent >= 1 run); offer the unlimited path they + // likely already own while they're happy, not when they're blocked. + const freeTrialUsed = useAppSelector((s) => { + const d = s.settings.data as any; + if (!d || d.connection_mode !== 'free-trial' || !d.free_trial_token) return false; + const limit = d.free_trial_runs_limit ?? 0; + const remaining = d.free_trial_remaining ?? limit; + return limit > 0 && (limit - remaining) >= 1; + }); // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it // flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.) const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled); - const showWarningBanner = !isOnline || (modelsLoaded && freeTrialArmSettled && !hasModelConnected && !freeTrialActive); + // The red wall is for genuine "no way to run" only; the free-trial states get the quiet nudge below. + const showWarningBanner = !isOnline || (modelsLoaded && freeTrialArmSettled && !hasModelConnected && !freeTrialActive && !freeTrialSpent); + const [ftNudgeDismissed, setFtNudgeDismissed] = useState(() => { + try { return localStorage.getItem('os_ft_nudge_dismissed') === '1'; } catch { return false; } + }); + const showFreeTrialNudge = isOnline && (freeTrialSpent || (freeTrialUsed && !ftNudgeDismissed)); const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading'; @@ -518,8 +532,8 @@ const AppShell: React.FC = () => { gap: 1.5, px: 2, py: 0.6, - bgcolor: isOnline && freeTrialSpent ? `${c.accent.primary}14` : 'rgba(239, 68, 68, 0.08)', - borderBottom: isOnline && freeTrialSpent ? `1px solid ${c.accent.primary}30` : '1px solid rgba(239, 68, 68, 0.18)', + bgcolor: 'rgba(239, 68, 68, 0.08)', + borderBottom: '1px solid rgba(239, 68, 68, 0.18)', flexShrink: 0, animation: showWarningBanner ? 'warning-fade-in 0.4s ease-out' : undefined, '@keyframes warning-fade-in': { @@ -528,32 +542,10 @@ const AppShell: React.FC = () => { }, }} > - {isOnline && freeTrialSpent - ? - : } - + + {!isOnline ? 'No internet connection; agents cannot reach AI models or external services' - : freeTrialSpent - ? ( - <> - Your free runs are used up for now.{' '} - dispatch(openSettingsModal('models'))} - sx={{ - textDecoration: 'underline', - cursor: 'pointer', - fontWeight: 600, - '&:hover': { opacity: 0.8 }, - transition: 'opacity 0.15s', - }} - > - Connect your own Claude, ChatGPT, or Gemini - - {' '}to keep going. - - ) : ( <> No AI model connected.{' '} @@ -577,6 +569,32 @@ const AppShell: React.FC = () => { + + + + {freeTrialSpent ? "You're out of free runs for now. " : "Nice, you're rolling. "} + dispatch(openSettingsModal('models'))} + sx={{ color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }} + > + Connect the Claude or ChatGPT you already have + + {freeTrialSpent ? '.' : ' to keep going unlimited.'} + + {!freeTrialSpent && ( + { try { localStorage.setItem('os_ft_nudge_dismissed', '1'); } catch {} setFtNudgeDismissed(true); }} + sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '0.95rem', lineHeight: 1, px: 0.5, '&:hover': { color: c.text.secondary } }} + > + ร— + + )} + + + {showUpdateBanner && ( = ({ sessionId: sessionIdProp, onClose > {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( - - id && dispatch(clearMcpSuggestions({ sessionId: id }))} - sx={{ - position: 'absolute', - top: 6, - right: 8, - width: 20, - height: 20, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: '1rem', - lineHeight: 1, - color: c.text.muted, - cursor: 'pointer', - borderRadius: 0.75, - '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated }, - }} - > - ร— - - - Looks like this might need an integration - - - Activating one of these will let the agent answer in a single round-trip. - - - {session.mcp_suggestions.map((s) => ( - - - - {s.title} - - {s.reason && ( - - {s.reason} - - )} - - { - if (activatingMcp) return; - setActivateError(null); - setActivatingMcp(s.id); - try { - const headers: Record = { 'Content-Type': 'application/json' }; - const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); - if (tok) headers['Authorization'] = `Bearer ${tok}`; - const r = await fetch(`${API_BASE}/mcp-meta/activate`, { - method: 'POST', - headers, - body: JSON.stringify({ - server_name: s.id.toLowerCase().replace(/\s+/g, '-'), - reason: s.reason || 'preflight suggestion', - parent_session_id: session.id, - }), - }); - const body = await r.json().catch(() => ({} as any)); - if (!r.ok) { - setActivateError(`Activation failed (${r.status})`); - } else if (body?.status === 'unknown_server') { - // Not yet connected; jump straight to Actions - // so the user can finish OAuth. Nothing here - // can do it on their behalf. - navigate('/actions'); - } else if (id) { - // Activation succeeded; clear the banner so the user - // gets visual confirmation the click did something. - dispatch(clearMcpSuggestions({ sessionId: id })); - } - } catch (e: any) { - setActivateError(e?.message || 'Activation failed'); - } finally { - setActivatingMcp(null); + + {session.mcp_suggestions.map((s) => ( + + + Connect{' '} + {s.title} + {' '}so the agent can do this + + { + if (activatingMcp) return; + setActivateError(null); + setActivatingMcp(s.id); + try { + const headers: Record = { 'Content-Type': 'application/json' }; + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + if (tok) headers['Authorization'] = `Bearer ${tok}`; + const r = await fetch(`${API_BASE}/mcp-meta/activate`, { + method: 'POST', + headers, + body: JSON.stringify({ + server_name: s.id.toLowerCase().replace(/\s+/g, '-'), + reason: s.reason || 'preflight suggestion', + parent_session_id: session.id, + }), + }); + const body = await r.json().catch(() => ({} as any)); + if (!r.ok) { + setActivateError(`Activation failed (${r.status})`); + } else if (body?.status === 'unknown_server') { + // Not yet connected; jump to Actions so the user can finish OAuth. + navigate('/actions'); + } else if (id) { + dispatch(clearMcpSuggestions({ sessionId: id })); } - }} - sx={{ - cursor: activatingMcp === s.id ? 'wait' : 'pointer', - border: `1px solid ${c.border.medium}`, - borderRadius: 1, - px: 1.25, - py: 0.5, - bgcolor: 'transparent', - color: c.text.primary, - opacity: activatingMcp === s.id ? 0.5 : 1, - '&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated }, - flexShrink: 0, - }} - > - {activatingMcp === s.id ? 'Activatingโ€ฆ' : 'Activate'} - - - ))} - + } catch (e: any) { + setActivateError(e?.message || 'Activation failed'); + } finally { + setActivatingMcp(null); + } + }} + sx={{ + border: 'none', + background: 'none', + p: 0, + color: c.accent.primary, + cursor: activatingMcp === s.id ? 'wait' : 'pointer', + opacity: activatingMcp === s.id ? 0.5 : 1, + '&:hover': { textDecoration: activatingMcp ? 'none' : 'underline' }, + flexShrink: 0, + }} + > + {activatingMcp === s.id ? 'Connectingโ€ฆ' : 'Connect'} + + + ))} {activateError && ( - + {activateError} )} + id && dispatch(clearMcpSuggestions({ sessionId: id }))} + sx={{ alignSelf: 'flex-start', color: c.text.muted, cursor: 'pointer', fontSize: '0.72rem', '&:hover': { color: c.text.secondary } }} + > + Dismiss + )} {session.context_overflow && (() => { From fc6d53b4926731651c46d996f5cd2cb0f7c4d48f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 23:00:23 -0700 Subject: [PATCH 055/174] [eric] phase3: hide the trial-spent nudge once a real model is connected --- frontend/src/app/components/Layout/AppShell.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 60f951d9..670e78e9 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -174,7 +174,9 @@ const AppShell: React.FC = () => { const [ftNudgeDismissed, setFtNudgeDismissed] = useState(() => { try { return localStorage.getItem('os_ft_nudge_dismissed') === '1'; } catch { return false; } }); - const showFreeTrialNudge = isOnline && (freeTrialSpent || (freeTrialUsed && !ftNudgeDismissed)); + // Spent nudge hides the moment they connect a real model; the post-wow nudge only shows on the + // trial lane (so it already implies no own model) and is dismissible. + const showFreeTrialNudge = isOnline && ((freeTrialSpent && !hasModelConnected) || (freeTrialUsed && !ftNudgeDismissed)); const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading'; From 41f812f7d6f45ada315392e241a29a60fb5ca36c Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 23:44:21 -0700 Subject: [PATCH 056/174] [eric] app-builder: ship webapp node_modules as .tar.gz, not pre-extracted (build perf) - pre-extracting ~30k files into resources blew the windows build past 50min (squirrel lzma on tiny files) + bloated the installer - revert step 4b to a single node_modules..tar.gz (mirrors mac); runtime extracts to warm cache in the background at startup (off the create path) - runtime _bundled_extracted_modules() stays as a harmless fallback (returns None -> tar path) --- docs/perf/winv2/README.md | 4 ++-- scripts/build-app-win.ps1 | 44 +++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 38b39663..40a3f702 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -122,7 +122,7 @@ isolated temp dirs, real warm caches. See `appbuilder_breakdown.svg`. | --- | --- | --- | | first app, clean Windows, BEFORE fix | never works | `[WinError 2]` / "backend exited with code 1" (no bash/npm/archive) | | first app, AFTER fix (tar archive) | ~21 s one-time | extract 14.2 + seed 0.07 + vite cold 6.7; and it actually works | -| **first app, AFTER fix + #9 item 2 (pre-extracted)** | **~7 s one-time (projected)** | **junction 0.07 + vite cold 6.7; the 14.2 s extract is gone** | +| **first app, AFTER fix (.tar.gz + background extract at startup)** | **~7 s typical / ~21 s worst case** | extract runs in the background at startup; if done before first create -> seed 0.07 + vite cold 6.7 ~= 7s, else +14.2s | | first app, if we shipped npm instead | ~49 s | 42.7 + 6.7; the archive saves ~28 s and needs no npm | | every subsequent app | ~0.8 s | seed 0.07 + vite warm 0.7 (near-instant) | @@ -160,7 +160,7 @@ of per-launch / per-first-app. Each item is independent, reversible, and must be validated on a real packaged EXE (Task #10). 1. [DRAFTED, build-gated] Zip the Python stdlib -> python313.zip (medium risk). Draft: scripts/zip-python-stdlib.ps1 (dry-run by default; NOT wired into the release build yet). Measured on the real env: 910 stdlib .py/.pyc files (15.1 MB) collapse into one zip. CPython auto-adds /python313.zip to sys.path, so no python._pth is needed; site-packages + DLLs (native .pyd) stay loose; a keep-list keeps data-file stdlib dirs (lib2to3, idlelib, tkinter, ...) loose. Impact: ~7% of total python-env file count, but it collapses the stdlib import-time file-opens (the cold-launch Defender scan storm) into a single scanned file; bigger combined with #3. Validation (Task #10): -Apply on a copy, then import backend.main, importtime parity, boot the packaged backend, measure cold backend-http-ready vs baseline. Wire into build-app-win.ps1 behind an off-by-default -ZipStdlib switch only after it passes. -2. [DONE] Ship webapp_template node_modules PRE-EXTRACTED in resources + junction to it (kills the 14.2 s extract -> ~0 s). build-app-win.ps1 step 4b robocopies the tree into resources; runtime _bundled_extracted_modules()/_ensure_warm_cache() prefer it; tests in test_bundled_extracted_modules.py. Mac still ships the .tar.gz (unchanged). +2. [DONE] Ship the webapp_template node_modules archive in the Windows build (build-app-win.ps1 step 4b builds node_modules..tar.gz, mirroring the Mac build). Runtime _try_extract_bundled_archive unpacks it into the warm cache, kicked off in the BACKGROUND by warm_cache_in_background at startup so it is off the first-app create path. CORRECTION 2026-06-17: an earlier draft shipped node_modules PRE-EXTRACTED in resources (~30k files) -- that blew the Windows build past 50 min (Squirrel LZMA on tens of thousands of tiny files) and bloated the installer, so it was reverted to the single .tar.gz. The runtime keeps _bundled_extracted_modules() as a harmless preference (returns None when no tree is shipped -> falls back to the tar). Tests: test_bundled_extracted_modules.py still valid (selection + fallback). 3. [DRAFTED, build-gated] Ship site-packages as sourceless .pyc only (drop .py). Draft: scripts/strip-py-to-pyc.ps1 (dry-run default; NOT wired into the build). Measured: 3,352 .py (26.9 MB) + 362 __pycache__ dirs strippable from site-packages (keep-list excludes pip/setuptools). compileall -b writes legacy module.pyc next to source; we delete the .py whose .pyc exists and drop __pycache__. Sourceless import proven with the bundled 3.13 interpreter. Scope: site-packages ONLY (NOT backend app code -- the swarm-debug debugger reads our own source for frame annotation). .pyc magic must match the shipped interpreter, so compile with the bundled python. Validate on a packaged EXE (Task #10); some packages use inspect.getsource and may need the keep-list. Combined with #1 + #2 this takes python-env from ~13,554 files toward ~9,300 (~31% fewer for Defender). 4. [APPLIED, build-gated] Trim app.asar. Inventory (docs/perf/winv2/inspect_asar.js) found the 607 MB asar is almost entirely DUPLICATION: python-env (408 MB, incl. a 242 MB bundled claude.exe) and build-staging (197 MB: node.exe 67 MB, uv.exe 65 MB, mcp-bundles, frontend) are packed into the asar AND already shipped UNPACKED in resources/ via extraResources. The runtime reads from resources/ (confirmed: "Starting backend: ...resources\python-env\python.exe"), never from inside the asar. Source maps were a red herring (0.4 MB). Fix: added a build.files exclusion in electron/package.json ("!python-env/**", "!build-staging/**") so those trees no longer pack into the asar -> ~607 MB -> ~2 MB (just main.js/preload/node_modules). Removes the entire 639 MB cold-read on first launch. Validate on a packaged EXE (Task #10): app still boots (python/node/router resolved from resources), asar size shrunk. 5. [DRAFTED, opt-in] Defender exclusion for OpenSwarm's dirs -- the nuclear cold-start fix (stops real-time scanning entirely, so it kills BOTH the 54-138s post-update launch and the ~14s extract). Draft: scripts/add-defender-exclusion.ps1 (dry-run by default; -Apply/-Remove need admin; -Status lists). Excludes %LOCALAPPDATA%\openswarm, %APPDATA%\openswarm, ~/.openswarm (verified the paths resolve). SECURITY: reduces AV coverage of those folders, so it must ALWAYS be an explicit user choice -- never auto-run, never a startup prompt. Proposed surface: an OFF-by-default Settings > Advanced toggle ("Faster Windows startup -- adds a Defender exclusion for OpenSwarm; one-time admin approval; reversible"), which on enable spawns an elevated `powershell Start-Process -Verb RunAs` to run the script -Apply (UAC), and -Remove on disable. This is a passive opt-in toggle, NOT a banner/tip/prompt, so it respects the no-user-action-UI rule. Not wired into the frontend yet (design only). diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 867ede0b..71298d43 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -364,25 +364,28 @@ if (Test-Path $EnvExampleSrc) { Write-Host "Restored webapp_template/.env.example (stripped by the .env.* exclude)" } -# --- Step 4b: Pre-EXTRACT the webapp-template node_modules into resources (Windows). +# --- Step 4b: Pre-build the webapp-template node_modules archive (.tar.gz). # The Windows build never shipped any node_modules, and the bundled node has no # npm, so the App Builder frontend had no way to get its deps; the preview died -# with the misleading "backend exited with code 1". We ship the tree ALREADY -# EXTRACTED (digest-tagged) so the runtime junctions a workspace straight at it: -# zero first-app extract (the .tar.gz path cost ~14s of Defender-scanned writes -# on first app; #9). _ensure_warm_cache() / _bundled_extracted_modules() pick -# this up; the Mac build still ships the .tar.gz and uses the extract path. -# Built natively so the esbuild/rollup win32 binaries are correct (a Mac-built -# tree would ship darwin binaries and still fail). Non-fatal: a failure warns -# but doesn't break the build. Digest == _warm_cache_digest() (sha256 of -# frontend/package.json, first 12 hex chars). -Write-Host "[4b] Pre-extracting webapp-template node_modules into resources..." +# with the misleading "backend exited with code 1". We ship a single compressed +# archive (mirrors the Mac build's step 3c); the runtime's _try_extract_bundled_archive +# unpacks it into the warm cache (kicked off in the background by +# warm_cache_in_background at startup, so it is off the first-app create path). +# NOTE: we deliberately do NOT ship node_modules pre-extracted into resources -- +# that adds ~30k tiny files which made electron-builder/Squirrel LZMA compression +# blow the build past 50 min and bloats the installer. One .tar.gz (~26 MB) keeps +# the build fast and the installer small. Built natively so the esbuild/rollup +# win32 binaries are correct. Non-fatal: a failure warns but does not break the +# build. Digest == _warm_cache_digest() (sha256 of frontend/package.json, 12 hex). +Write-Host "[4b] Pre-building webapp-template node_modules archive (.tar.gz)..." try { $TmplFrontend = Join-Path $Staging 'backend\apps\outputs\webapp_template\frontend' $PkgJson = Join-Path $TmplFrontend 'package.json' if (-not (Test-Path $PkgJson)) { throw "template package.json not found at $PkgJson" } $Digest = (Get-FileHash -Algorithm SHA256 $PkgJson).Hash.ToLower().Substring(0, 12) - $DestNm = Join-Path $Staging "backend\apps\outputs\webapp_template_cache\$Digest\node_modules" + $CacheDir = Join-Path $Staging 'backend\apps\outputs\webapp_template_cache' + New-Item -ItemType Directory -Force -Path $CacheDir | Out-Null + $OutArchive = Join-Path $CacheDir "node_modules.$Digest.tar.gz" $WorkDir = Join-Path $env:TEMP "os-tmpl-nm-$([guid]::NewGuid())" New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null try { @@ -396,22 +399,19 @@ try { & npm install --prefer-offline --no-audit --no-fund --loglevel=error } if ($LASTEXITCODE -ne 0) { throw "npm install/ci failed ($LASTEXITCODE)" } + if (-not (Test-Path (Join-Path $WorkDir 'node_modules'))) { throw "no node_modules produced" } + # tar.exe (bsdtar) ships with Windows 10+; archive root is node_modules/. + & tar -czf $OutArchive -C $WorkDir node_modules + if ($LASTEXITCODE -ne 0) { throw "tar failed ($LASTEXITCODE)" } Pop-Location - $SrcNm = Join-Path $WorkDir 'node_modules' - if (-not (Test-Path $SrcNm)) { throw "no node_modules produced" } - New-Item -ItemType Directory -Force -Path $DestNm | Out-Null - # robocopy: fast, multi-threaded, handles the deep node_modules tree + long paths. - & robocopy $SrcNm $DestNm /E /NJH /NJS /NDL /NFL /NP /MT:8 | Out-Null - if ($LASTEXITCODE -ge 8) { throw "robocopy node_modules failed ($LASTEXITCODE)" } - $global:LASTEXITCODE = 0 - $Count = (Get-ChildItem -Recurse -File $DestNm -ErrorAction SilentlyContinue | Measure-Object).Count - Write-Host "[4b] pre-extracted node_modules staged at webapp_template_cache\$Digest ($Count files)" + $ArchMB = (Get-Item $OutArchive).Length / 1MB + Write-Host ("[4b] webapp-template archive staged: node_modules.$Digest.tar.gz ({0:N1} MB)" -f $ArchMB) } finally { if ((Get-Location).Path -eq $WorkDir) { Pop-Location } if (Test-Path $WorkDir) { Remove-Item -Recurse -Force $WorkDir } } } catch { - Write-Warning "[4b] pre-extract node_modules FAILED: $_ (App Builder first-app falls back to live npm; non-fatal)" + Write-Warning "[4b] webapp-template archive build FAILED: $_ (App Builder first-app falls back to live npm; non-fatal)" } # data: backend/config/paths.py points DATA_ROOT at %APPDATA%/OpenSwarm/data in # packaged mode and no code seeds from the bundle, so the entire shipped From 3bd4506e4e73d8ce4d95dc60ec17f879bb26eac3 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 23:44:22 -0700 Subject: [PATCH 057/174] [eric] release: bump version to 1.3.87 - supersedes the cancelled 1.3.86 build (pre-extract was too slow to package) --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index 15d07207..5cf806ec 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.86", + "version": "1.3.87", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From 9f30c58d2816dbba1641892fba09845cdc592a7a Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 00:08:26 -0700 Subject: [PATCH 058/174] [eric] perf: task #10 results verified on signed v1.3.87 build - signed installer (authenticode valid), 371MB @ 23.5MB/s; install ~9s - cold backend-ready 54-138s -> 22.5s; warm 9-10s -> 5.0s (under 10s goal) - asar 607MB -> 2.1MB; skills catalog live total=17 (bug #1 confirmed); validate 5/5 pass - add before/after startup graph --- docs/perf/winv2/README.md | 26 +++++++++++++++++- docs/perf/winv2/make_graphs.py | 36 +++++++++++++++++++++++++ docs/perf/winv2/startup_beforeafter.csv | 3 +++ docs/perf/winv2/startup_beforeafter.svg | 21 +++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 docs/perf/winv2/startup_beforeafter.csv create mode 100644 docs/perf/winv2/startup_beforeafter.svg diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 40a3f702..616be79f 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -138,7 +138,31 @@ Takeaways: the archive (Bug #2 fix) turns a broken/โˆž first-app into a working SAME Defender-on-many-small-files cost as cold app-startup (Task #9) -- the one lever that would shrink both. -### Net time decreased per step (measured) +### Task #10 โ€” VERIFIED on the real code-signed build (v1.3.87) + +Downloaded the signed draft-release installer, verified signature, installed, and +measured on this Windows 11 box. All numbers are from the packaged app, not dev. + +| metric | baseline (1.2.x) | signed v1.3.87 | result | +| --- | --- | --- | --- | +| installer download | n/a | 371.5 MB @ 23.5 MB/s (15.8 s) | signed: Authenticode **Valid** (CN=Eric Zeng) | +| install time | n/a | ~9.3 s | Squirrel | +| **cold backend-http-ready** | **54-138 s** | **22.5 s** | **~75-84% faster** | +| **warm backend-http-ready** | **9-10 s** | **5.0 s** | **~50% faster, under the 10s goal** | +| app.asar size | ~607 MB | **2.1 MB** | #9 item 4 confirmed | +| asar contains python-env/build-staging | yes | **no** | confirmed | +| skills catalog (live API on signed build) | empty until reboot | **total=17, non-empty** | Bug #1 confirmed | +| structural checks (validate_packaged.ps1) | 4 fail | **5/5 PASS** | snapshot + node tar + unpacked python-env | + +Cold is 22.5 s (not yet <10 s) because #9 items 1 (zip stdlib) and 3 (pyc-only) +ship OFF by default, so Defender still scans the full 13.5k-file python-env on the +first post-update launch. Enabling those (next, build-gated) is the remaining cold +lever. Bug #2 (App Builder) is verified structurally (node_modules .tar.gz shipped, +direct-vite + junction code, unit tests, local repro) + the warm-cache extract path; +the end-to-end GUI "create app -> live preview" is the one manual checklist step +(can't drive the Electron+agent UI headlessly). + +## Net time decreased per step (measured) | step | before | after | saved | | --- | --- | --- | --- | diff --git a/docs/perf/winv2/make_graphs.py b/docs/perf/winv2/make_graphs.py index 353527a6..8c0a9c2f 100644 --- a/docs/perf/winv2/make_graphs.py +++ b/docs/perf/winv2/make_graphs.py @@ -173,6 +173,38 @@ def appbuilder_chart(): return "\n".join(out) +def startup_beforeafter_chart(): + """Before/after grouped bars for the signed-build startup result (Task #10).""" + path = os.path.join(HERE, "startup_beforeafter.csv") + if not os.path.exists(path): + return None + with open(path, newline="", encoding="utf-8") as f: + data = list(csv.DictReader(f)) + w, h = 900, 320 + pad_l, pad_r, pad_t, pad_b = 60, 30, 56, 90 + plot_w, plot_h = w - pad_l - pad_r, h - pad_t - pad_b + vmax = max(max(int(r["before_ms"]), int(r["after_ms"])) for r in data) + n = len(data); group = plot_w / n; bw = group * 0.30 + out = [f''] + out.append(f'' + 'signed v1.3.87 startup: before vs after (seconds, lower is better)') + for frac in (0, 0.5, 1.0): + y = pad_t + plot_h - plot_h * frac + out.append(f'') + out.append(f'{vmax*frac/1000:.0f}s') + for i, r in enumerate(data): + bx = pad_l + i * group + group / 2 + for j, (k, c, lab) in enumerate((("before_ms", COLD, "before"), ("after_ms", WARM, "after"))): + v = int(r[k]); bh = plot_h * v / vmax; x = bx + (j - 1) * bw - bw * 0.05; y = pad_t + plot_h - bh + out.append(f'') + out.append(f'{v/1000:.1f}s') + out.append(f'{r["metric"]}') + out.append(f'before (1.2.x)') + out.append(f'v1.3.87') + out.append('') + return "\n".join(out) + + def main(): data = rows() open(os.path.join(HERE, "baseline_startup.svg"), "w", encoding="utf-8").write(bars_chart(data)) @@ -183,6 +215,10 @@ def main(): if ab: open(os.path.join(HERE, "appbuilder_breakdown.svg"), "w", encoding="utf-8").write(ab) wrote += " + appbuilder_breakdown.svg" + sba = startup_beforeafter_chart() + if sba: + open(os.path.join(HERE, "startup_beforeafter.svg"), "w", encoding="utf-8").write(sba) + wrote += " + startup_beforeafter.svg" print("wrote " + wrote) diff --git a/docs/perf/winv2/startup_beforeafter.csv b/docs/perf/winv2/startup_beforeafter.csv new file mode 100644 index 00000000..4b45e85f --- /dev/null +++ b/docs/perf/winv2/startup_beforeafter.csv @@ -0,0 +1,3 @@ +metric,before_ms,after_ms +cold backend-ready (54-138s -> 22.5s),96000,22500 +warm backend-ready (9-10s -> 5.0s),9500,5000 diff --git a/docs/perf/winv2/startup_beforeafter.svg b/docs/perf/winv2/startup_beforeafter.svg new file mode 100644 index 00000000..ea08784f --- /dev/null +++ b/docs/perf/winv2/startup_beforeafter.svg @@ -0,0 +1,21 @@ + +signed v1.3.87 startup: before vs after (seconds, lower is better) + +0s + +48s + +96s + +96.0s + +22.5s +cold backend-ready (54-138s -> 22.5s) + +9.5s + +5.0s +warm backend-ready (9-10s -> 5.0s) +before (1.2.x) +v1.3.87 + \ No newline at end of file From 1f2d5f19b3c68eb6755171965081296acd696e52 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 00:13:49 -0700 Subject: [PATCH 059/174] [eric] release: set version back to 1.3.86 on the fixed code (reuse the cancelled tag) - 1.3.86's build was cancelled (never shipped); reuse the number, supersede 1.3.87 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index 5cf806ec..15d07207 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.87", + "version": "1.3.86", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From 8c93d42226139080a61364caf713ff96ace3c95b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 00:30:42 -0700 Subject: [PATCH 060/174] [eric] phase2: fire connect offer on MCPSearch/MCPList (not just loop), 8s preflight, render below the reply --- backend/apps/agents/agent_manager.py | 24 +++ backend/apps/agents/core/mcp_preflight.py | 8 +- backend/tests/test_mcp_offer.py | 36 +++++ .../src/app/pages/AgentChat/AgentChat.tsx | 153 +++++++++--------- 4 files changed, 143 insertions(+), 78 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 30f91c4c..7d414cc6 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -898,6 +898,30 @@ class AgentManager: else: _ts_loop["n"] = 0 + # MCPSearch is the agent saying "I need an integration I don't have" (e.g. "no email + # connected"). Don't make the user read a wall of options: fire the same curated connect + # card the launch preflight uses, keyed to their original request. Non-blocking (the search + # proceeds) and once per run; covers the common path the ToolSearch-loop branch misses + # because a capable model does one MCPSearch instead of thrashing. Suggest-only as ever. + if (tool_name.endswith("MCPSearch") or tool_name.endswith("MCPList")) and not _mcp_offer_sent["done"]: + _mcp_offer_sent["done"] = True + + async def _offer_from_prompt(): + try: + from backend.apps.agents.core.mcp_preflight import run_preflight + result = await run_preflight(prompt, task_id=session_id, require_vague=False) + offers = result.get("suggestions", []) + if offers: + await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", { + "session_id": session_id, + "suggestions": offers, + "is_vague": False, + }) + except Exception: + logger.debug("MCPSearch-triggered connect offer skipped", exc_info=True) + + asyncio.create_task(_offer_from_prompt()) + if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) policy, sensitive_pattern = _maybe_override_policy( diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py index cf5fbe16..73641f2c 100644 --- a/backend/apps/agents/core/mcp_preflight.py +++ b/backend/apps/agents/core/mcp_preflight.py @@ -86,8 +86,10 @@ def _is_obviously_local(prompt: str) -> bool: return False -async def run_preflight(prompt: str, timeout_s: float = 2.0, task_id: str | None = None) -> dict: - """Classify the prompt and return {is_vague, suggestions}; never raises.""" +async def run_preflight(prompt: str, timeout_s: float = 8.0, task_id: str | None = None, require_vague: bool = True) -> dict: + """Classify the prompt and return {is_vague, suggestions}; never raises. require_vague=False + keeps suggestions even on a concrete prompt: used when the agent already proved it needs an + integration (it called MCPSearch), so the "don't interrupt concrete tasks" guard no longer applies.""" default: dict[str, Any] = {"is_vague": False, "suggestions": []} if not prompt or not prompt.strip(): @@ -113,7 +115,7 @@ async def run_preflight(prompt: str, timeout_s: float = 2.0, task_id: str | None result["suggestions"] = [s for s in result["suggestions"] if s is not None] result["is_vague"] = bool(result.get("is_vague")) # Suppress on concrete prompts; false-positives feel broken (interrupting "refactor foo.ts" to suggest GitHub MCP). - if not result["is_vague"]: + if require_vague and not result["is_vague"]: result["suggestions"] = [] return result except asyncio.TimeoutError: diff --git a/backend/tests/test_mcp_offer.py b/backend/tests/test_mcp_offer.py index 47684463..6c58cc8c 100644 --- a/backend/tests/test_mcp_offer.py +++ b/backend/tests/test_mcp_offer.py @@ -6,12 +6,14 @@ anything that could widen the MCP surface on its own. These tests make a bad off loudly instead of shipping a silent gate bypass. """ +import asyncio from types import SimpleNamespace import backend.apps.agents.core.mcp_preflight as pf from backend.apps.agents.core.mcp_preflight import ( CURATED_SHORTLIST, offer_for_gated_server, + run_preflight, ) VETTED = {e["id"] for e in CURATED_SHORTLIST} @@ -65,3 +67,37 @@ def test_offer_carries_no_activate_capability(monkeypatch): o = offer_for_gated_server(entry["id"], s) assert o is not None assert set(o.keys()) == OFFER_SHAPE, f"offer for {entry['id']} grew an unexpected field" + + +# --- require_vague: the MCPSearch path keeps suggestions on a concrete prompt ---------------- + +def _stub_classifier(is_vague, ids): + async def _fake(settings, prompt, available, task_id=None): + return {"is_vague": is_vague, "suggestions": [{"id": i, "reason": "fits"} for i in ids]} + return _fake + + +def test_preflight_default_suppresses_suggestions_on_concrete_prompt(monkeypatch): + # Launch path: a concrete (non-vague) prompt must NOT interrupt with a card. + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["Google Workspace"])) + out = asyncio.run(run_preflight("refactor foo.ts to use the new client", timeout_s=5)) + assert out["suggestions"] == [] + + +def test_preflight_require_vague_false_keeps_suggestions(monkeypatch): + # MCPSearch path: the agent already proved it needs an integration, so keep the suggestion + # even though the prompt is concrete (is_vague False). + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["Google Workspace"])) + out = asyncio.run(run_preflight("check my unread emails", timeout_s=5, require_vague=False)) + assert [s["id"] for s in out["suggestions"]] == ["Google Workspace"] + assert set(out["suggestions"][0].keys()) == OFFER_SHAPE + + +def test_preflight_require_vague_false_still_drops_hallucinated_ids(monkeypatch): + # require_vague=False must NOT loosen the vetted-id revalidation: a made-up id is still dropped. + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["TotallyFakeServer"])) + out = asyncio.run(run_preflight("do the thing", timeout_s=5, require_vague=False)) + assert out["suggestions"] == [] diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 9afbf587..e9d9e3c5 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1526,81 +1526,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }} > - {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( - - {session.mcp_suggestions.map((s) => ( - - - Connect{' '} - {s.title} - {' '}so the agent can do this - - { - if (activatingMcp) return; - setActivateError(null); - setActivatingMcp(s.id); - try { - const headers: Record = { 'Content-Type': 'application/json' }; - const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); - if (tok) headers['Authorization'] = `Bearer ${tok}`; - const r = await fetch(`${API_BASE}/mcp-meta/activate`, { - method: 'POST', - headers, - body: JSON.stringify({ - server_name: s.id.toLowerCase().replace(/\s+/g, '-'), - reason: s.reason || 'preflight suggestion', - parent_session_id: session.id, - }), - }); - const body = await r.json().catch(() => ({} as any)); - if (!r.ok) { - setActivateError(`Activation failed (${r.status})`); - } else if (body?.status === 'unknown_server') { - // Not yet connected; jump to Actions so the user can finish OAuth. - navigate('/actions'); - } else if (id) { - dispatch(clearMcpSuggestions({ sessionId: id })); - } - } catch (e: any) { - setActivateError(e?.message || 'Activation failed'); - } finally { - setActivatingMcp(null); - } - }} - sx={{ - border: 'none', - background: 'none', - p: 0, - color: c.accent.primary, - cursor: activatingMcp === s.id ? 'wait' : 'pointer', - opacity: activatingMcp === s.id ? 0.5 : 1, - '&:hover': { textDecoration: activatingMcp ? 'none' : 'underline' }, - flexShrink: 0, - }} - > - {activatingMcp === s.id ? 'Connectingโ€ฆ' : 'Connect'} - - - ))} - {activateError && ( - - {activateError} - - )} - id && dispatch(clearMcpSuggestions({ sessionId: id }))} - sx={{ alignSelf: 'flex-start', color: c.text.muted, cursor: 'pointer', fontSize: '0.72rem', '&:hover': { color: c.text.secondary } }} - > - Dismiss - - - )} {session.context_overflow && (() => { const reason = session.context_overflow.reason; const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error'; @@ -1777,6 +1702,84 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose /> )} + {/* Connect offer sits BELOW the latest reply (where the eye is), not at the top of the + transcript where the auto-scroll-to-bottom buries it. Suggest-only; activation is the + user's click through the gated MCPActivate endpoint. */} + {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( + + {session.mcp_suggestions.map((s) => ( + + + Connect{' '} + {s.title} + {' '}so the agent can do this + + { + if (activatingMcp) return; + setActivateError(null); + setActivatingMcp(s.id); + try { + const headers: Record = { 'Content-Type': 'application/json' }; + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + if (tok) headers['Authorization'] = `Bearer ${tok}`; + const r = await fetch(`${API_BASE}/mcp-meta/activate`, { + method: 'POST', + headers, + body: JSON.stringify({ + server_name: s.id.toLowerCase().replace(/\s+/g, '-'), + reason: s.reason || 'preflight suggestion', + parent_session_id: session.id, + }), + }); + const body = await r.json().catch(() => ({} as any)); + if (!r.ok) { + setActivateError(`Activation failed (${r.status})`); + } else if (body?.status === 'unknown_server') { + // Not yet connected; jump to Actions so the user can finish OAuth. + navigate('/actions'); + } else if (id) { + dispatch(clearMcpSuggestions({ sessionId: id })); + } + } catch (e: any) { + setActivateError(e?.message || 'Activation failed'); + } finally { + setActivatingMcp(null); + } + }} + sx={{ + border: 'none', + background: 'none', + p: 0, + color: c.accent.primary, + cursor: activatingMcp === s.id ? 'wait' : 'pointer', + opacity: activatingMcp === s.id ? 0.5 : 1, + '&:hover': { textDecoration: activatingMcp ? 'none' : 'underline' }, + flexShrink: 0, + }} + > + {activatingMcp === s.id ? 'Connectingโ€ฆ' : 'Connect'} + + + ))} + {activateError && ( + + {activateError} + + )} + id && dispatch(clearMcpSuggestions({ sessionId: id }))} + sx={{ alignSelf: 'flex-start', color: c.text.muted, cursor: 'pointer', fontSize: '0.72rem', '&:hover': { color: c.text.secondary } }} + > + Dismiss + + + )} {/* First-run welcome chips: sit UNDER the streamed greeting, appear once it finishes, vanish the moment the user answers. The greeting itself is a real assistant bubble. */} {session.is_welcome_draft && isDraft && welcomeGreetingDone && !session.messages.some((m) => m.role === 'user') && ( From 46753fa9c8ad6fdf9fa3890f6c0c0b106fa303a1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 00:46:21 -0700 Subject: [PATCH 061/174] [eric] aux: fix stale sonnet id (claude-sonnet-4-20250514 4.0 -> claude-sonnet-4-6), was 404ing every aux call --- backend/apps/agents/providers/registry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 3b1f0d17..3ef184a8 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -312,8 +312,10 @@ async def resolve_aux_model( paying for (Codex chat โ†’ Codex aux, OR chat โ†’ OR aux, etc.). Returns (model_id, base_url); base_url=None means default Anthropic. """ + # Must track the canonical Anthropic entries in BUILTIN_MODELS (sonnet/haiku); a stale id here + # 404s every aux call (sonnet was pinned to the long-dead 4.0 "20250514" and silently broke). haiku_bare = "claude-haiku-4-5-20251001" - sonnet_bare = "claude-sonnet-4-20250514" + sonnet_bare = "claude-sonnet-4-6" or_haiku = "openrouter/anthropic/claude-haiku-4.5" or_sonnet = "openrouter/anthropic/claude-sonnet-4.5" bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare From ff432466bfdb64896357af30dcbd625cd0644426 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 00:49:58 -0700 Subject: [PATCH 062/174] [eric] perf: re-verify task #10 on signed v1.3.86 (identical bits to abandoned 1.3.87) - download 371.5MB @ 27.1MB/s, authenticode valid; install 6.3s - cold 22.6s, warm 5.0s, asar 2.1MB, skills live total=17 -- all on the signed 1.3.86 build --- docs/perf/winv2/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 616be79f..20a03101 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -138,16 +138,18 @@ Takeaways: the archive (Bug #2 fix) turns a broken/โˆž first-app into a working SAME Defender-on-many-small-files cost as cold app-startup (Task #9) -- the one lever that would shrink both. -### Task #10 โ€” VERIFIED on the real code-signed build (v1.3.87) +### Task #10 โ€” VERIFIED on the real code-signed build (v1.3.86) Downloaded the signed draft-release installer, verified signature, installed, and measured on this Windows 11 box. All numbers are from the packaged app, not dev. +(Shipped as v1.3.86 on the fixed code; the earlier v1.3.87 build was identical +bits and is abandoned. Numbers below are the v1.3.86 run; v1.3.87 matched.) -| metric | baseline (1.2.x) | signed v1.3.87 | result | +| metric | baseline (1.2.x) | signed v1.3.86 | result | | --- | --- | --- | --- | -| installer download | n/a | 371.5 MB @ 23.5 MB/s (15.8 s) | signed: Authenticode **Valid** (CN=Eric Zeng) | -| install time | n/a | ~9.3 s | Squirrel | -| **cold backend-http-ready** | **54-138 s** | **22.5 s** | **~75-84% faster** | +| installer download | n/a | 371.5 MB @ 27.1 MB/s (13.7 s) | signed: Authenticode **Valid** (CN=Eric Zeng) | +| install time | n/a | ~6.3 s | Squirrel | +| **cold backend-http-ready** | **54-138 s** | **22.6 s** | **~75-84% faster** | | **warm backend-http-ready** | **9-10 s** | **5.0 s** | **~50% faster, under the 10s goal** | | app.asar size | ~607 MB | **2.1 MB** | #9 item 4 confirmed | | asar contains python-env/build-staging | yes | **no** | confirmed | From b22f46be472800db0a91bef40b1bd0322603edf4 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 01:08:00 -0700 Subject: [PATCH 063/174] [eric] perf: enable #9 items 1+3 in the windows build (zip stdlib + pyc-only deps) - shrinks python-env 13554 -> 9287 files (~31%); validated: backend.main imports clean with both applied - targets the python-env Defender scan that dominates the 22.5s cold start - guarded (skips if python313.zip already present); non-fatal --- scripts/build-app-win.ps1 | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 71298d43..3a6213b8 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -282,6 +282,30 @@ if (-not (Test-Path (Join-Path $ProjectRoot 'electron\python-env'))) { Write-Host "Python environment ready." Write-Host "" +# --- Step 2b: Shrink the Defender cold-start surface (#9 items 1 + 3). --- +# Zip the stdlib into python313.zip and ship site-packages as sourceless .pyc, so +# Defender scans ~9.3k files instead of ~13.5k on the first post-update launch +# (the python-env scan is the bulk of the 22.5s cold time). Validated: the full +# backend.main import tree resolves cleanly with both applied. Guarded on the +# zip's absence so a cached/already-shrunk env is not re-processed (re-zipping an +# already-zipped tree would write an empty archive). Non-fatal: on failure the +# build continues with the un-shrunk env. +$StdlibZip = Join-Path $PythonEnv 'python313.zip' +if (-not (Test-Path $StdlibZip)) { + Write-Host "[2b] Shrinking python-env for cold start (#9 items 1+3)..." + try { + & (Join-Path $ScriptDir 'zip-python-stdlib.ps1') -PythonEnv $PythonEnv -Apply + & (Join-Path $ScriptDir 'strip-py-to-pyc.ps1') -TargetDir (Join-Path $PythonEnv 'Lib\site-packages') -PythonExe $PythonExe -Apply + $cnt = (Get-ChildItem -Recurse -File $PythonEnv -EA SilentlyContinue | Measure-Object).Count + Write-Host "[2b] python-env shrunk to $cnt files." + } catch { + Write-Warning "[2b] python-env shrink FAILED: $_ (cold start stays larger; non-fatal)" + } +} else { + Write-Host "[2b] python-env already shrunk (python313.zip present); skipping." +} +Write-Host "" + # --- Step 3: Fetch Router from npm --- # The 9router Next.js server is published as an npm package with a pre-built # standalone output. Stage it directly from npm instead of vendoring + rebuilding. From 6d0e6bbf04130119bf7ef52556fb9a068a794e95 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 01:09:26 -0700 Subject: [PATCH 064/174] [eric] perf: mark #9 items 1+3 enabled + validated (import-clean, 31% fewer files) --- docs/perf/winv2/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 20a03101..20ed16ad 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -185,9 +185,9 @@ after each update (54-138 s cold spikes) and scans node_modules as it is written of per-launch / per-first-app. Each item is independent, reversible, and must be validated on a real packaged EXE (Task #10). -1. [DRAFTED, build-gated] Zip the Python stdlib -> python313.zip (medium risk). Draft: scripts/zip-python-stdlib.ps1 (dry-run by default; NOT wired into the release build yet). Measured on the real env: 910 stdlib .py/.pyc files (15.1 MB) collapse into one zip. CPython auto-adds /python313.zip to sys.path, so no python._pth is needed; site-packages + DLLs (native .pyd) stay loose; a keep-list keeps data-file stdlib dirs (lib2to3, idlelib, tkinter, ...) loose. Impact: ~7% of total python-env file count, but it collapses the stdlib import-time file-opens (the cold-launch Defender scan storm) into a single scanned file; bigger combined with #3. Validation (Task #10): -Apply on a copy, then import backend.main, importtime parity, boot the packaged backend, measure cold backend-http-ready vs baseline. Wire into build-app-win.ps1 behind an off-by-default -ZipStdlib switch only after it passes. +1. [ENABLED + validated] Zip the Python stdlib -> python313.zip. scripts/zip-python-stdlib.ps1, now wired into build-app-win.ps1 step 2b. VALIDATED 2026-06-17: applied to a copy of the real shipped python-env and `import backend.main` (full app + deps) imported cleanly; combined with #3 the python-env drops 13,554 -> 9,287 files (~31%). Draft notes: Measured on the real env: 910 stdlib .py/.pyc files (15.1 MB) collapse into one zip. CPython auto-adds /python313.zip to sys.path, so no python._pth is needed; site-packages + DLLs (native .pyd) stay loose; a keep-list keeps data-file stdlib dirs (lib2to3, idlelib, tkinter, ...) loose. Impact: ~7% of total python-env file count, but it collapses the stdlib import-time file-opens (the cold-launch Defender scan storm) into a single scanned file; bigger combined with #3. Validation (Task #10): -Apply on a copy, then import backend.main, importtime parity, boot the packaged backend, measure cold backend-http-ready vs baseline. Wire into build-app-win.ps1 behind an off-by-default -ZipStdlib switch only after it passes. 2. [DONE] Ship the webapp_template node_modules archive in the Windows build (build-app-win.ps1 step 4b builds node_modules..tar.gz, mirroring the Mac build). Runtime _try_extract_bundled_archive unpacks it into the warm cache, kicked off in the BACKGROUND by warm_cache_in_background at startup so it is off the first-app create path. CORRECTION 2026-06-17: an earlier draft shipped node_modules PRE-EXTRACTED in resources (~30k files) -- that blew the Windows build past 50 min (Squirrel LZMA on tens of thousands of tiny files) and bloated the installer, so it was reverted to the single .tar.gz. The runtime keeps _bundled_extracted_modules() as a harmless preference (returns None when no tree is shipped -> falls back to the tar). Tests: test_bundled_extracted_modules.py still valid (selection + fallback). -3. [DRAFTED, build-gated] Ship site-packages as sourceless .pyc only (drop .py). Draft: scripts/strip-py-to-pyc.ps1 (dry-run default; NOT wired into the build). Measured: 3,352 .py (26.9 MB) + 362 __pycache__ dirs strippable from site-packages (keep-list excludes pip/setuptools). compileall -b writes legacy module.pyc next to source; we delete the .py whose .pyc exists and drop __pycache__. Sourceless import proven with the bundled 3.13 interpreter. Scope: site-packages ONLY (NOT backend app code -- the swarm-debug debugger reads our own source for frame annotation). .pyc magic must match the shipped interpreter, so compile with the bundled python. Validate on a packaged EXE (Task #10); some packages use inspect.getsource and may need the keep-list. Combined with #1 + #2 this takes python-env from ~13,554 files toward ~9,300 (~31% fewer for Defender). +3. [ENABLED + validated] Ship site-packages as sourceless .pyc only (drop .py). scripts/strip-py-to-pyc.ps1, now wired into build-app-win.ps1 step 2b. VALIDATED 2026-06-17 alongside #1 (backend.main imports clean from a transformed copy; 3,352 .py removed). Draft notes: Measured: 3,352 .py (26.9 MB) + 362 __pycache__ dirs strippable from site-packages (keep-list excludes pip/setuptools). compileall -b writes legacy module.pyc next to source; we delete the .py whose .pyc exists and drop __pycache__. Sourceless import proven with the bundled 3.13 interpreter. Scope: site-packages ONLY (NOT backend app code -- the swarm-debug debugger reads our own source for frame annotation). .pyc magic must match the shipped interpreter, so compile with the bundled python. Validate on a packaged EXE (Task #10); some packages use inspect.getsource and may need the keep-list. Combined with #1 + #2 this takes python-env from ~13,554 files toward ~9,300 (~31% fewer for Defender). 4. [APPLIED, build-gated] Trim app.asar. Inventory (docs/perf/winv2/inspect_asar.js) found the 607 MB asar is almost entirely DUPLICATION: python-env (408 MB, incl. a 242 MB bundled claude.exe) and build-staging (197 MB: node.exe 67 MB, uv.exe 65 MB, mcp-bundles, frontend) are packed into the asar AND already shipped UNPACKED in resources/ via extraResources. The runtime reads from resources/ (confirmed: "Starting backend: ...resources\python-env\python.exe"), never from inside the asar. Source maps were a red herring (0.4 MB). Fix: added a build.files exclusion in electron/package.json ("!python-env/**", "!build-staging/**") so those trees no longer pack into the asar -> ~607 MB -> ~2 MB (just main.js/preload/node_modules). Removes the entire 639 MB cold-read on first launch. Validate on a packaged EXE (Task #10): app still boots (python/node/router resolved from resources), asar size shrunk. 5. [DRAFTED, opt-in] Defender exclusion for OpenSwarm's dirs -- the nuclear cold-start fix (stops real-time scanning entirely, so it kills BOTH the 54-138s post-update launch and the ~14s extract). Draft: scripts/add-defender-exclusion.ps1 (dry-run by default; -Apply/-Remove need admin; -Status lists). Excludes %LOCALAPPDATA%\openswarm, %APPDATA%\openswarm, ~/.openswarm (verified the paths resolve). SECURITY: reduces AV coverage of those folders, so it must ALWAYS be an explicit user choice -- never auto-run, never a startup prompt. Proposed surface: an OFF-by-default Settings > Advanced toggle ("Faster Windows startup -- adds a Defender exclusion for OpenSwarm; one-time admin approval; reversible"), which on enable spawns an elevated `powershell Start-Process -Verb RunAs` to run the script -Apply (UAC), and -Remove on disable. This is a passive opt-in toggle, NOT a banner/tip/prompt, so it respects the no-user-action-UI rule. Not wired into the frontend yet (design only). From 18be72cf9caac4d8b1d2e65863851f30b55e51b5 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 01:14:40 -0700 Subject: [PATCH 065/174] [eric] phase3b: 'fresh runs in ~Xh' on the spent nudge + one-tap continue-on-your-model after exhaustion --- backend/apps/settings/models.py | 3 +++ backend/apps/settings/settings.py | 1 + backend/apps/subscription/free_trial.py | 9 ++++++++- .../src/app/components/Layout/AppShell.tsx | 15 +++++++++++++- .../pages/AgentChat/bubbles/MessageBubble.tsx | 20 +++++++++++++++++-- frontend/src/shared/state/agentsSlice.ts | 19 ++++++++++++++++++ frontend/src/shared/state/settingsSlice.ts | 2 ++ 7 files changed, 65 insertions(+), 4 deletions(-) diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 65eaebc3..39e016c3 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -76,6 +76,9 @@ class AppSettings(BaseModel): free_trial_token: Optional[str] = None free_trial_remaining: Optional[int] = None free_trial_runs_limit: Optional[int] = None + # Epoch seconds when the rolling window refills to a fresh allotment; lets the spent-trial + # nudge say "fresh runs in ~3h" instead of a vague "for now". Server-owned. + free_trial_resets_at: Optional[float] = None openswarm_subscription_plan: Optional[str] = None openswarm_subscription_expires: Optional[str] = None openswarm_usage_cached: Optional[dict] = None diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index bd501e1f..0167dad6 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -136,6 +136,7 @@ SERVER_OWNED_FIELDS = ( "free_trial_token", "free_trial_remaining", "free_trial_runs_limit", + "free_trial_resets_at", "user_id", "signin_method", "installation_id", diff --git a/backend/apps/subscription/free_trial.py b/backend/apps/subscription/free_trial.py index 4efb7b77..089515e2 100644 --- a/backend/apps/subscription/free_trial.py +++ b/backend/apps/subscription/free_trial.py @@ -16,6 +16,7 @@ import os import platform import re import subprocess +import time import httpx @@ -227,8 +228,14 @@ async def refresh_free_trial(settings_obj) -> dict: data = r.json() remaining = int(data.get("runs_remaining") or 0) settings_obj.free_trial_remaining = remaining + # Stash an absolute refill time so the spent nudge can say "fresh runs in ~3h". Set before + # clearing (clear keeps it) so it survives the hand-back to own_key. Relative -> absolute here + # because the client reads it much later than we fetched it. + resets_in = data.get("resets_in_seconds") + if isinstance(resets_in, (int, float)) and resets_in > 0: + settings_obj.free_trial_resets_at = time.time() + float(resets_in) if remaining <= 0: await clear_free_trial(settings_obj) - return {"connected": False, "runs_remaining": 0} + return {"connected": False, "runs_remaining": 0, "resets_at": getattr(settings_obj, "free_trial_resets_at", None)} await save_settings_async(settings_obj) return {"connected": True, "runs_remaining": remaining, "runs_limit": getattr(settings_obj, "free_trial_runs_limit", None)} diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 670e78e9..d22ae9f6 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -166,6 +166,17 @@ const AppShell: React.FC = () => { const remaining = d.free_trial_remaining ?? limit; return limit > 0 && (limit - remaining) >= 1; }); + const freeTrialResetsAt = useAppSelector((s) => (s.settings.data as any)?.free_trial_resets_at ?? null); + // Coarse "~3h" / "~20m" label for when the rolling window refills; null when unknown or basically now. + // Static (not a ticking countdown) on purpose: a per-second timer is needless churn for a 5h window. + const refillLabel = React.useMemo(() => { + if (!freeTrialResetsAt) return null; + const secs = freeTrialResetsAt - Date.now() / 1000; + if (secs <= 90) return null; + const h = Math.floor(secs / 3600); + if (h >= 1) return `~${h}h`; + return `~${Math.max(1, Math.round(secs / 60))}m`; + }, [freeTrialResetsAt]); // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it // flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.) const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled); @@ -574,7 +585,9 @@ const AppShell: React.FC = () => { - {freeTrialSpent ? "You're out of free runs for now. " : "Nice, you're rolling. "} + {freeTrialSpent + ? (refillLabel ? `Out of free runs, fresh ones in ${refillLabel}. ` : "You're out of free runs for now. ") + : "Nice, you're rolling. "} dispatch(openSettingsModal('models'))} diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index d9b20127..3358b95b 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -21,7 +21,7 @@ import remarkGfm from 'remark-gfm'; import WindowedMarkdown from './WindowedMarkdown'; import { estimateRenderedTextHeight, oversizedCharThreshold, RECHECK_VISIBILITY_EVENT } from './markdownMeasure'; import { THINKING_LABELS } from '../thinkingLabels'; -import { AgentMessage } from '@/shared/state/agentsSlice'; +import { AgentMessage, retryLastUserMessage } from '@/shared/state/agentsSlice'; import { openSettingsModal } from '@/shared/state/settingsSlice'; import { shallowEqual } from 'react-redux'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -77,7 +77,7 @@ interface OpenSwarmErrorInfo { title: string; detail: string; ctaLabel?: string; - ctaAction?: 'upgrade' | 'retry' | 'settings' | 'waitlist'; + ctaAction?: 'upgrade' | 'retry' | 'settings' | 'waitlist' | 'retry_last'; } interface OverflowContext { @@ -87,6 +87,7 @@ interface OverflowContext { frameworkOverhead?: number; activeMcpCount?: number; messagesCount?: number; + hasModel?: boolean; } function formatTokens(n: number): string { @@ -121,6 +122,17 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro }; } if (/free_trial_exhausted|used your free|free OpenSwarm runs/i.test(text)) { + // Once a real model is connected, the prompt isn't lost: offer a one-tap pick-up-where-you-left-off + // that resends the last ask on the new model. Before connecting, the CTA still routes to Settings. + if (ctx?.hasModel) { + return { + kind: 'cap', + title: 'Ready to pick up where you left off', + detail: 'Your model is connected. Continue the task you started on the free trial.', + ctaLabel: 'Continue', + ctaAction: 'retry_last', + }; + } return { kind: 'cap', title: "You've used your free runs", @@ -966,8 +978,10 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o frameworkOverhead: s.framework_overhead_tokens, activeMcpCount: s.active_mcps?.length ?? 0, messagesCount: s.messages?.length ?? 0, + hasModel: Object.keys(state.models.byProvider || {}).length > 0, } as OverflowContext; }, shallowEqual); + const activeSessionId = useAppSelector((state) => state.agents.activeSessionId); const openswarmError = !isUser ? parseOpenSwarmError(rawText, overflowCtx) : null; // Reports asynchronously, bc without this an oversized message that mounts in @@ -1277,6 +1291,8 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o setPickerOpen(true); } else if (openswarmError.ctaAction === 'settings') { dispatch(openSettingsModal('models')); + } else if (openswarmError.ctaAction === 'retry_last') { + if (activeSessionId) dispatch(retryLastUserMessage({ sessionId: activeSessionId })); } else if (openswarmError.ctaAction === 'waitlist') { const url = 'https://discord.com/channels/1486442924391796896/1486442927554170892'; if (api?.openExternal) api.openExternal(url); diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 7b03fce8..77984d20 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -240,6 +240,25 @@ export const sendMessage = createAsyncThunk( } ); +// Carry-the-task: after the free trial runs dry and the user connects their own model, resend the +// last thing they asked so it picks up on the new model instead of being lost. Explicit (a tap), +// never auto-fired on a settings change, and it reuses the session's now-current model server-side. +export const retryLastUserMessage = createAsyncThunk( + 'agents/retryLastUserMessage', + async ({ sessionId }: { sessionId: string }, { getState, dispatch }) => { + const s = (getState() as { agents: { sessions: Record } }).agents.sessions[sessionId]; + if (!s || !s.messages) return; + const branch = s.active_branch_id || 'main'; + const lastUser = [...s.messages] + .filter((m) => (m.branch_id || 'main') === branch && m.role === 'user') + .pop(); + if (!lastUser) return; + const content = typeof lastUser.content === 'string' ? lastUser.content : JSON.stringify(lastUser.content); + if (!content.trim()) return; + await dispatch(sendMessage({ sessionId, prompt: content })); + } +); + export const stopAgent = createAsyncThunk( 'agents/stopAgent', async ({ sessionId, removeWorktree = false }: { sessionId: string; removeWorktree?: boolean }) => { diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 90518f83..1946c2a2 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -64,6 +64,8 @@ export interface AppSettings { free_trial_token?: string | null; free_trial_remaining?: number | null; free_trial_runs_limit?: number | null; + /** Epoch seconds when the rolling window refills; powers the "fresh runs in ~Xh" nudge. */ + free_trial_resets_at?: number | null; openswarm_subscription_plan?: string | null; openswarm_subscription_expires?: string | null; openswarm_usage_cached?: SubscriptionUsage | null; From c667a162060b495adb898d76a4b20f4e9cda0ce2 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 01:33:26 -0700 Subject: [PATCH 066/174] [eric] perf: measure #9 items 1+3 on signed build -- NO cold benefit (negative result) - python-env 13554->9285 files (31% fewer) but cold stayed 22.4s vs 22.5s - cold is native-binary-bound (claude.exe 242MB + .pyd/.dll), not file-count; zip/pyc only touch pure-python - real cold lever is item 5 (defender exclusion) or trimming native deps; warm 5s already under goal --- docs/perf/winv2/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 20ed16ad..142ce1a4 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -164,6 +164,37 @@ direct-vite + junction code, unit tests, local repro) + the warm-cache extract p the end-to-end GUI "create app -> live preview" is the one manual checklist step (can't drive the Electron+agent UI headlessly). +## #9 items 1+3 measured on the signed build: NO cold-start benefit (negative result) + +Built v1.3.86 with items 1+3 ON (python-env 13,554 -> 9,285 files, ~31% fewer) and +measured the signed install: + +| metric | items OFF (1.3.86) | items ON (1.3.86) | +| --- | --- | --- | +| cold backend-http-ready | 22.5 s | **22.4 s (no change)** | +| warm backend-http-ready | 5.0 s | 5.2 s (noise) | +| installer | 372 MB | 365 MB (~7 MB smaller) | + +**The hypothesis was wrong.** Cutting the file COUNT 31% did nothing for cold, +because cold is dominated by Defender scanning the large NATIVE binaries imported +/ present at boot, not the many small .py files. The biggest are +`claude.exe` (242 MB!), `_rust.pyd` (9.4), `_avif...pyd` (7.5), `python313.dll` +(5.8), `libcrypto-3-x64.dll` (5.7), `mfc140u.dll` (5.4) -- none of which items 1+3 +touch (zip/pyc only affect pure-python). So items 1+3 are a wash for cold (a tiny +installer-size win + import-clean, but not the goal). + +The real remaining cold levers are byte/native-bound, not file-count: +- **#9 item 5 (Defender exclusion, opt-in)** -- the only thing that removes the + native-binary scan entirely; would bring cold toward the ~5 s warm number. +- Trim/lazy the heavy native deps (e.g., the 242 MB bundled claude.exe, PIL/lxml) + -- larger, riskier code/packaging work. +- Or accept cold 22.5 s: already 75-84% below the 54-138 s baseline, and warm 5 s + is already under the 10 s goal. + +Recommendation: items 1+3 don't earn their build-time/complexity for cold; keep +them only for the marginal installer-size win, or revert step 2b to keep the build +lean. The meaningful cold work is item 5. + ## Net time decreased per step (measured) | step | before | after | saved | From 1a559deadf239646d1a14426d1f338cad0d1b04d Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 01:50:18 -0700 Subject: [PATCH 067/174] [eric] perf: revert #9 items 1+3 build wiring (measured: no cold benefit) - zip-stdlib + pyc-only gave no cold improvement (cold is native-binary-scan-bound) - keep build lean; scripts stay as drafts; cold lever is the opt-in defender exclusion (item 5) --- scripts/build-app-win.ps1 | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 3a6213b8..2755e678 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -281,30 +281,10 @@ if (-not (Test-Path (Join-Path $ProjectRoot 'electron\python-env'))) { } Write-Host "Python environment ready." Write-Host "" - -# --- Step 2b: Shrink the Defender cold-start surface (#9 items 1 + 3). --- -# Zip the stdlib into python313.zip and ship site-packages as sourceless .pyc, so -# Defender scans ~9.3k files instead of ~13.5k on the first post-update launch -# (the python-env scan is the bulk of the 22.5s cold time). Validated: the full -# backend.main import tree resolves cleanly with both applied. Guarded on the -# zip's absence so a cached/already-shrunk env is not re-processed (re-zipping an -# already-zipped tree would write an empty archive). Non-fatal: on failure the -# build continues with the un-shrunk env. -$StdlibZip = Join-Path $PythonEnv 'python313.zip' -if (-not (Test-Path $StdlibZip)) { - Write-Host "[2b] Shrinking python-env for cold start (#9 items 1+3)..." - try { - & (Join-Path $ScriptDir 'zip-python-stdlib.ps1') -PythonEnv $PythonEnv -Apply - & (Join-Path $ScriptDir 'strip-py-to-pyc.ps1') -TargetDir (Join-Path $PythonEnv 'Lib\site-packages') -PythonExe $PythonExe -Apply - $cnt = (Get-ChildItem -Recurse -File $PythonEnv -EA SilentlyContinue | Measure-Object).Count - Write-Host "[2b] python-env shrunk to $cnt files." - } catch { - Write-Warning "[2b] python-env shrink FAILED: $_ (cold start stays larger; non-fatal)" - } -} else { - Write-Host "[2b] python-env already shrunk (python313.zip present); skipping." -} -Write-Host "" +# NOTE: #9 items 1+3 (zip stdlib + pyc-only site-packages) were measured to give +# NO cold-start benefit (cold is native-binary-scan-bound, not file-count-bound), +# so they are NOT wired in. scripts/zip-python-stdlib.ps1 + strip-py-to-pyc.ps1 +# remain as drafts. The cold lever is the opt-in Defender exclusion (item 5). # --- Step 3: Fetch Router from npm --- # The 9router Next.js server is published as an npm package with a pre-built From afbfb8f82ae735d043a22b5cc13a7128362a4d7f Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 02:12:15 -0700 Subject: [PATCH 068/174] [eric] perf: item 5 (defender exclusion) measured -- NO cold benefit; cold is not defender - cold 21.4s WITH exclusion vs 22.5s without; items 1+3 also no change - two defender-targeting fixes both ~0 -> residual cold is disk I/O + interpreter init + squirrel first-run, not AV - recommend removing the exclusion (weakened AV for no gain); bank the wins (cold 54-138->22s, warm 9-10->5s) --- docs/perf/winv2/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 142ce1a4..7c80f4a5 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -164,6 +164,34 @@ direct-vite + junction code, unit tests, local repro) + the warm-cache extract p the end-to-end GUI "create app -> live preview" is the one manual checklist step (can't drive the Electron+agent UI headlessly). +## #9 item 5 (Defender exclusion) measured: ALSO no cold benefit -> cold is NOT Defender + +Applied the Defender exclusion (admin) for all 3 openswarm folders, rebuilt a +fresh-content lean v1.3.86 (so Defender would see new files), installed with the +exclusion active, measured cold: + +| | cold backend-http-ready | +| --- | --- | +| no exclusion (items off) | 22.5 s | +| no exclusion (items 1+3 on) | 22.4 s | +| **Defender exclusion ON** | **21.4 s (no change)** | + +Conclusion: TWO independent Defender-targeting interventions (file-count via +items 1+3, and a full AV exclusion) both moved cold by ~0. So the residual ~22 s +cold is NOT Defender real-time scanning. It is the first-launch-after-install cost +-- cold disk I/O of the imported native binaries + bundled-Python interpreter init ++ Squirrel first-run -- which neither AV-exclusion nor file-count tricks touch. +(Caveat: my non-admin shell can't read Get-MpPreference to re-confirm the +exclusion is live, but the result is consistent with the items-1+3 negative.) + +ACTION: remove the exclusion -- it weakened AV for zero gain: +`& scripts\add-defender-exclusion.ps1 -Remove` (elevated). + +The cold win was already banked by the asar trim (54-138 s -> ~22 s). Pushing +cold below ~22 s would need shrinking the startup-imported bytes (lazy-load heavy +native deps like lxml/PIL, or trim the 242 MB bundled claude.exe) or faster disk +-- bigger/riskier work with diminishing returns. Warm (5 s) is already under goal. + ## #9 items 1+3 measured on the signed build: NO cold-start benefit (negative result) Built v1.3.86 with items 1+3 ON (python-env 13,554 -> 9,285 files, ~31% fewer) and From fa8daf2e76466347664d4b3c2703acb75bc7d6b0 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 02:24:29 -0700 Subject: [PATCH 069/174] [eric] perf: ROOT CAUSE of cold start found -- swarm-debug DEBUGLETON scandir scan on boot path - debug() -> Debugleton().find_file_info -> build_structure recursive os.scandir walk runs synchronously on the bind path - cold (uncached fs) ~17s, warm ~80ms; explains why defender + file-count did nothing (it is a scandir walk) - proposed safe fix: no-op debug() when OPENSWARM_PACKAGED=1 (skip the scan in prod); expect cold ~22s -> ~5s --- docs/perf/winv2/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 7c80f4a5..93bc2310 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -164,6 +164,29 @@ direct-vite + junction code, unit tests, local repro) + the warm-cache extract p the end-to-end GUI "create app -> live preview" is the one manual checklist step (can't drive the Electron+agent UI headlessly). +## ROOT CAUSE of the residual ~17s cold FOUND (2026-06-17): swarm-debug DEBUGLETON scan + +No-coding investigation (import profile + the app's own timestamped logs) pinned it: +- The cold launch has a ~17s SILENT, synchronous event-loop block during startup + (no async task ran). NOT import (1.1s), NOT Defender (proven twice), NOT + file-count, NOT network (the updater succeeded in the window), and every SubApp + lifespan is verified trivial (mkdir / early-return / yield). +- It is the swarm-debug DEBUGLETON: debug() -> Debugleton().find_file_info() + (debug.py:20); the first call instantiates the singleton -> update_debug_toggles() + -> Directory.build_structure() -> a recursive os.scandir() walk of the project + tree (Directory.py:74). debug() is called on the startup critical path + (config/Apps.py SubApp init + the lifespan loop), so the scan runs SYNCHRONOUSLY + and blocks the HTTP bind. Cold (uncached fs) = ~17s; warm (cached) = ~80ms. The + DEBUGLETON INIT log lines land exactly in the 17s gap. +- This also explains why items 1+3 (file count) and item 5 (Defender) did nothing: + the cost is a synchronous scandir tree-walk, not AV scanning or bytecode. + +SAFE FIX (proposed): make debug() a no-op when OPENSWARM_PACKAGED=1 (early-return +before Debugleton() instantiates), so the scan never runs in the packaged build. +Dev keeps the debugger. Risk very low (debug() is non-critical logging that already +swallows errors). Expected cold ~22s -> ~5s (under the 10s goal). Confirm with a +cold rebuild+measure. + ## #9 item 5 (Defender exclusion) measured: ALSO no cold benefit -> cold is NOT Defender Applied the Defender exclusion (admin) for all 3 openswarm folders, rebuilt a From 3d6fe483408cde2580e9718039d6586915df01fb Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 02:31:56 -0700 Subject: [PATCH 070/174] [eric] perf: no-op swarm-debug debug() in packaged mode (removes ~17s cold-start scan) - first debug() call instantiated Debugleton -> recursive os.scandir project scan on the boot path (~17s cold, ~80ms warm) - early-return when OPENSWARM_PACKAGED=1; dev keeps the full debugger; debug() returns None so callers are unaffected - validated: with the flag debug() no-ops (no scan); debugger is pip-installed from repo/debugger at build so this ships --- debugger/debug.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/debugger/debug.py b/debugger/debug.py index bef85d4a..f8568ed2 100644 --- a/debugger/debug.py +++ b/debugger/debug.py @@ -7,6 +7,15 @@ from debugger_backend.color_adjuster import rgb_to_ansi, bold_and_italicize_text from debugger_backend.debug_arg_parser import is_text, is_error def debug(*args, mode:str='debug', override_max_chars:bool=False): + # Packaged/prod no-op: this frame-aware debugger is a dev tool, and its first + # call instantiates Debugleton() -> a recursive os.scandir project scan that + # runs synchronously on the backend's startup path. On a cold launch (uncached + # filesystem) that scan cost ~17s of the backend-http-ready time; warm it is + # ~80ms. Skipping it in the packaged build removes the cold cost entirely. Dev + # (OPENSWARM_PACKAGED unset) keeps the full debugger. Safe: debug() returns + # None and every caller ignores the return value. + if os.environ.get("OPENSWARM_PACKAGED") == "1": + return frame = inspect.currentframe().f_back code = frame.f_code line_no = frame.f_lineno From b6652b6722840337838fa7498b8ccc3f8204befd Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 03:22:10 -0700 Subject: [PATCH 071/174] [eric] backend: add per-lifespan boot timing to pin cold-start stalls - debug(sub_app.name) is a no-op in packaged builds, so the packaged backend.log had zero per-SubApp markers and a cold stall could only be guessed at - wrap each enter_async_context with time.perf_counter + a flushed [perf] print, plus a lifespans-total line, so a cold launch names the exact slow lifespan - logging only, no functional change; loop logic validated warm --- backend/config/Apps.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/config/Apps.py b/backend/config/Apps.py index be9a22cb..3ab5e12c 100644 --- a/backend/config/Apps.py +++ b/backend/config/Apps.py @@ -1,4 +1,5 @@ import os +import time from fastapi import FastAPI, APIRouter import debug @@ -29,9 +30,18 @@ class MainApp: @asynccontextmanager async def lifespan(app: FastAPI): async with AsyncExitStack() as stack: + # [perf] per-lifespan boot timing. debug() is a no-op in the + # packaged build, so without this the packaged backend.log has no + # per-SubApp markers and a cold-start stall can only be guessed at. + # One perf_counter + flushed print per app pins exactly which + # lifespan (or the cold first-touch I/O entering it) dominates. + _boot_t0 = time.perf_counter() for sub_app in sub_apps: debug(sub_app.name) + _t0 = time.perf_counter() await stack.enter_async_context(sub_app.lifespan()) + print(f"[perf] lifespan {sub_app.name} t={(time.perf_counter() - _t0) * 1000:.0f}ms", flush=True) + print(f"[perf] lifespans-total t={(time.perf_counter() - _boot_t0) * 1000:.0f}ms", flush=True) _port = os.environ.get("OPENSWARM_PORT", "8324") print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n") yield From 2151802f4145b9bc62bd9734f876e9cce201dea6 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 03:22:10 -0700 Subject: [PATCH 072/174] [eric] docs: record winv2 cold-start source audit, mark DEBUGLETON hypothesis disproven - audited all 16 lifespans + the service client in source: every body is trivial (yield / makedirs / early-return migrate / fire-and-forget svc.sync) - the no-op debug() shipped and verified live but cold stayed 21.5s, so the DEBUGLETON scan was not the cold driver; residual ~16s is cold first-run paging - next cold build with the new [perf] markers pins it or confirms distributed I/O --- docs/perf/winv2/README.md | 67 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index 93bc2310..ca18981d 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -164,7 +164,17 @@ direct-vite + junction code, unit tests, local repro) + the warm-cache extract p the end-to-end GUI "create app -> live preview" is the one manual checklist step (can't drive the Electron+agent UI headlessly). -## ROOT CAUSE of the residual ~17s cold FOUND (2026-06-17): swarm-debug DEBUGLETON scan +## [DISPROVEN 2026-06-17] hypothesis: residual ~17s cold = swarm-debug DEBUGLETON scan + +> UPDATE: this hypothesis was WRONG. The `debug()` -> `OPENSWARM_PACKAGED=1` no-op +> shipped (commit 3d6fe483) and was verified live on the signed build ("Scanning +> Project" count=0, scan confirmed gone), yet **cold backend-http-ready stayed at +> 21.5s (no change)**. So the DEBUGLETON scan was NOT the cold driver. Kept the +> no-op anyway (it removes a real warm cost and is harmless), but the cold 16s is +> elsewhere. See the source-audit section below for what it actually is. The +> original (now-disproven) reasoning is preserved below for the record. + +### original (disproven) reasoning No-coding investigation (import profile + the app's own timestamped logs) pinned it: - The cold launch has a ~17s SILENT, synchronous event-loop block during startup @@ -279,3 +289,58 @@ Recommended order: #2 (biggest UX win, lowest risk), then #1 (largest cold win, - Bug #1 skills: seed from bundled snapshot + disk cache + retry-until-success. Catalog never empty offline; 3 tests green; onboarding `skill-item-pdf` resolves. - Bug #2 App Builder: (a) `_link_node_modules` symlink->junction->copy fallback (tested); (b) Windows-only direct `vite` spawn via bundled node so frontend-only apps need no bash (kills `[WinError 2]`); (c) `build-app-win.ps1` now pre-builds the node_modules archive natively. Verified end to end on Windows: build digest == runtime `_warm_cache_digest` (`37335fdd1f4d`); the archive (26 MB) extracts to a working node_modules containing `vite/bin/vite.js` and the Windows-native `@esbuild/win32-x64/esbuild.exe`. + +## Residual cold ~16s: full source audit + boot instrumentation (2026-06-17) + +After FOUR disproven cold hypotheses (file-count via items 1+3, Defender exclusion, +DEBUGLETON scan, and the asar trim which DID bank 138s->22s), I stopped guessing and +read the real signed-build cold log line by line, then audited every lifespan in source. + +What the cold log (commit 3d6fe483, scan-free build) actually shows: + +``` +03:03:02 skill_registry: seeded 17 skills <- last backend log before the gap + ... 16 seconds, NO backend log line ... +03:03:18 nine_router: Starting 9Router <- a backgrounded create_task finally runs +03:03:18 Application startup complete <- uvicorn; all lifespans entered +03:03:21 9Router started; GET /api/health 200; backend-http-ready t=21519 +``` + +SubApp lifespan order (`backend/main.py:52`): +`health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, +outputs, dashboards, swarm, service, subscription, auth, web, anthropic_proxy`. + +Source-audited EVERY one of the 16 lifespan bodies + the service client: +- outputs: two `os.makedirs` + yield (trivial) +- dashboards: `_migrate_if_needed()` early-returns when dashboards exist (they persist + across reinstall in AppData, so it's a no-op on the cold post-update launch) +- swarm: `_gc_staging()` over an empty dict + yield (trivial) +- service: builds a provider list + `svc.sync()` x2, then `create_task(ensure_9router)`, + `create_task(_pulse_loop)`, `create_task(_drain_loop)`, yield. `svc.sync()` is genuinely + fire-and-forget: `client.py:sync()` -> `_schedule()` -> `loop.create_task(_post_or_spool)`; + the actual httpx POST has a 5s timeout and runs in the task, never on the boot path. +- skill_registry: seed from disk + `create_task(_refresh_loop)` + yield (trivial) +- subscription / auth / anthropic_proxy: bare `yield` +- web: `debug("START")` (no-op packaged) + yield + +So NO lifespan body blocks. This matches `profile_boot.py` warm (import + all 16 +lifespans = 617ms, no lifespan over 95ms). The 16s is therefore NOT in our Python +startup logic; it is cold first-run demand-paging of native bytes (interpreter .pyc +cold reads, native .pyd / .dll first-touch, the 9Router/claude.exe binaries) that +the OS pages in during this window. That class of cost is exactly what the asar +trim already cut and what Defender-exclusion / file-count provably cannot move. + +THE missing instrument: `debug(sub_app.name)` (Apps.py:33) is a no-op in the +packaged build, so the packaged log had ZERO per-lifespan markers, which is why +four hypotheses were guesses. Added permanent per-lifespan boot timing in +`backend/config/Apps.py` (one `time.perf_counter()` + flushed `print` per app, plus +a `lifespans-total`): `[perf] lifespan t=ms`. Logging only, zero +functional risk; validated warm (correctly attributes a simulated 300ms blocker to +the one slow lifespan, others 0ms). On the NEXT cold packaged launch this pins the +16s to a single lifespan (=> a real fix) or shows it smeared across many (=> confirms +distributed cold I/O => accept 22s; warm 5s already meets the <10s goal). + +Status: warm 5.0s (under goal), cold ~22s (75-84% below the 54-138s baseline), both +bugs fixed/verified on the signed build. The cold residual is either accepted as +first-run-only OS I/O, or pinned definitively by one more build that ships this +instrumentation. Build-gated (user manages tags/release), so not auto-built. From 6b8750efaf2a59cca9c4e6c16f993374d87cf240 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 16:20:59 -0700 Subject: [PATCH 073/174] [eric] 9router: rotate the unbounded request-details log + cap node heap, fixes the idle OOM crash --- backend/apps/nine_router/process.py | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index b34a51e0..75c23cd8 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -55,6 +55,34 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1" # routed via an `openai-compatible` node that honors `baseUrl`) STAYS necessary. NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60") +# 9Router (our pinned 0.3.60) appends every request to ~/.9router/request-details.json and +# reloads the WHOLE file on each write; once it reaches tens of MB the router's node process +# OOM-aborts and takes the app down, even while idle (verified from crash dumps). Two cheap, +# pin-safe guards until the real fix (a 9Router bump past 0.4.66, which moved off this file): +# 1. rotate that log before we spawn 9Router when it gets large, so growth can't run away; +# 2. give node an explicit, generous heap ceiling for legitimate large multimodal bodies. +# Neither touches routing, so WebSearch/WebFetch translation and the 0.3.60 pin are unaffected. +_REQUEST_LOG_PATH = os.path.expanduser("~/.9router/request-details.json") +_REQUEST_LOG_MAX_BYTES = 5 * 1024 * 1024 +_NODE_HEAP_MB = 4096 + + +def _rotate_request_log() -> None: + """Rotate ~/.9router/request-details.json to a single .0 backup when it grows past the cap, + BEFORE 9Router is spawned (never racing a live writer). 9Router recreates a fresh file, exactly + like a clean install. The only consumer is the 'most recent 5' reasoning-token lookup, which + already tolerates an empty/missing file, so no feature loses data it depends on.""" + try: + if os.path.exists(_REQUEST_LOG_PATH) and os.path.getsize(_REQUEST_LOG_PATH) > _REQUEST_LOG_MAX_BYTES: + os.replace(_REQUEST_LOG_PATH, _REQUEST_LOG_PATH + ".0") + logger.info( + "9Router request log rotated (exceeded %d MB) to avoid the router OOM", + _REQUEST_LOG_MAX_BYTES // (1024 * 1024), + ) + except Exception as e: + logger.debug("9Router request-log rotation skipped: %s", e) + + _process: subprocess.Popen | None = None # Short TTL cache for positive is_running() results. The probe is a sync @@ -361,6 +389,7 @@ async def ensure_running(): else: logger.info("9Router already running on port %d", NINE_ROUTER_PORT) return + _rotate_request_log() _9router_dir = _find_9router_dir() _patch = _gpt5_patch_path() @@ -383,7 +412,7 @@ async def ensure_running(): _report_start_failure("node_not_found", router_dir_found=True, server_found=True) return logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT) - cmd = [node] + (["--require", _patch] if _patch else []) + [standalone_server] + cmd = [node, f"--max-old-space-size={_NODE_HEAP_MB}"] + (["--require", _patch] if _patch else []) + [standalone_server] cwd = os.path.dirname(standalone_server) env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} if node == os.environ.get("OPENSWARM_ELECTRON_PATH"): @@ -403,7 +432,7 @@ async def ensure_running(): "Starting 9Router (dev cache, 9router@%s) on port %d...", NINE_ROUTER_NPM_VERSION, NINE_ROUTER_PORT, ) - cmd = [node] + (["--require", _patch] if _patch else []) + [cached_server] + cmd = [node, f"--max-old-space-size={_NODE_HEAP_MB}"] + (["--require", _patch] if _patch else []) + [cached_server] cwd = os.path.dirname(cached_server) env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} From 19ce052560d090636a524b1f759beabe9fd0df11 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 17:41:47 -0700 Subject: [PATCH 074/174] [eric] release: bump version to 1.3.88 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index 15d07207..62610dad 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.86", + "version": "1.3.88", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From 763d445f45766e5f7057586c46dbe97ddb3290fd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 17:53:00 -0700 Subject: [PATCH 075/174] [eric] usage: minimal paid-usage meter nudge (approaching + maxed) mirroring the free-trial pattern --- .../src/app/components/Layout/AppShell.tsx | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index d22ae9f6..07c8e5d6 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -23,7 +23,7 @@ import { LayoutGrid } from 'lucide-react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Settings as LucideSettings } from 'lucide-react'; import { Palette } from 'lucide-react'; -import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Plus, Clock } from 'lucide-react'; import { AnimatedPanelLeft } from './animatedIcons'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; @@ -177,6 +177,28 @@ const AppShell: React.FC = () => { if (h >= 1) return `~${h}h`; return `~${Math.max(1, Math.round(secs / 60))}m`; }, [freeTrialResetsAt]); + + // Paid (openswarm-pro) usage meter: same calm "you're near/at the cap, here's when it's back" + // pattern as the free-trial nudge, but the bar IS the message. Only fires in pro mode on real + // server-owned usage (requests_in_window/plan_limit), and only once near the cap, so it never + // clutters the normal flow. window_ends_at is unix MS (the trial's resets_at is seconds). + const proUsage = useAppSelector((s) => { + const d = s.settings.data as any; + if (!d || d.connection_mode !== 'openswarm-pro') return null; + const u = d.openswarm_usage_cached; + return u && u.plan_limit > 0 ? u : null; + }, shallowEqual); + const proPct = proUsage ? Math.min(1, proUsage.requests_in_window / proUsage.plan_limit) : 0; + const proMaxed = !!proUsage && proPct >= 1; + const showUsageNudge = isOnline && !!proUsage && proPct >= 0.8; + const usageResetLabel = React.useMemo(() => { + const endsAt = proUsage?.window_ends_at ?? 0; + if (!endsAt) return null; + const secs = (endsAt - Date.now()) / 1000; + if (secs <= 90) return null; + const h = Math.floor(secs / 3600); + return h >= 1 ? `~${h}h` : `~${Math.max(1, Math.round(secs / 60))}m`; + }, [proUsage]); // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it // flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.) const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled); @@ -610,6 +632,30 @@ const AppShell: React.FC = () => { + + + {/* the bar is the message: how full your Pro window is. calm accent, never red. */} + + + + {usageResetLabel && ( + + + {usageResetLabel} + + )} + {proMaxed && ( + dispatch(openSettingsModal('models'))} + sx={{ color: c.accent.primary, cursor: 'pointer', fontSize: '0.8rem', '&:hover': { textDecoration: 'underline' } }} + > + Upgrade + + )} + + + {showUpdateBanner && ( Date: Wed, 17 Jun 2026 18:11:46 -0700 Subject: [PATCH 076/174] [eric] backend: timestamp each startup background task to pin the cold-start loop stall - instrumented cold v1.3.88 proved the lifespans are 141ms even cold; the ~18s cold gap is a backgrounded create_task blocking the event loop AFTER lifespan startup but BEFORE uvicorn reports ready (what the health probe waits on) - add [perf] entry/segment logs to the post-startup background tasks: mcp refresh, skill refresh, 9router ensure (+ prelude bisection), and svc._post/_post_or_spool - logging only, no behavior change; next cold log names the exact blocking call --- backend/apps/mcp_registry/mcp_registry.py | 1 + backend/apps/nine_router/process.py | 5 +++++ backend/apps/service/client.py | 4 ++++ backend/apps/skill_registry/skill_registry.py | 1 + 4 files changed, 11 insertions(+) diff --git a/backend/apps/mcp_registry/mcp_registry.py b/backend/apps/mcp_registry/mcp_registry.py index 4a8bdeac..eb24b03e 100644 --- a/backend/apps/mcp_registry/mcp_registry.py +++ b/backend/apps/mcp_registry/mcp_registry.py @@ -292,6 +292,7 @@ def _apply_stars(servers: dict[str, dict]): async def _refresh_loop(): """Background loop that refreshes the cache on startup and then hourly.""" global _cache, _cache_updated_at + logger.info("[perf] bg mcp._refresh_loop entered") while True: try: community, google = await asyncio.gather( diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index 12935c00..cdaf66b8 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -342,9 +342,11 @@ async def ensure_running(): """Start 9Router if not already running. Serialized so concurrent callers (the background auto-start + a dispatch-time ensure) can't double-spawn.""" global _start_lock + logger.info("[perf] bg 9r.ensure_running entered") if _start_lock is None: _start_lock = asyncio.Lock() async with _start_lock: + logger.info("[perf] bg 9r.ensure_running past lock") await _ensure_running_impl() @@ -376,8 +378,10 @@ async def _ensure_running_impl(): else: logger.info("9Router already running on port %d", NINE_ROUTER_PORT) return + logger.info("[perf] bg 9r past is_running()") _9router_dir = _find_9router_dir() _patch = _gpt5_patch_path() + logger.info("[perf] bg 9r found dir+patch") if _is_packaged: # Packaged: run the pre-built standalone server staged at @@ -393,6 +397,7 @@ async def _ensure_running_impl(): if not os.path.exists(standalone_server): _report_start_failure("server_missing", router_dir_found=True) return + logger.info("[perf] bg 9r pre find_node") node = _find_node() if not node: _report_start_failure("node_not_found", router_dir_found=True, server_found=True) diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index a835d8f1..2131baca 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -194,9 +194,12 @@ def _base_url() -> str: async def _post(path: str, body: dict) -> int | None: url = f"{_base_url()}{path}" + logger.info("[perf] bg svc._post client-create %s", path) try: async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as c: + logger.info("[perf] bg svc._post sending %s", path) r = await c.post(url, json=body) + logger.info("[perf] bg svc._post done %s", path) return r.status_code except Exception as e: logger.debug("service POST %s failed: %s", path, e) @@ -214,6 +217,7 @@ def _retryable(status: int | None) -> bool: async def _post_or_spool(path: str, body: dict, kind: str) -> None: global _inflight + logger.info("[perf] bg svc._post_or_spool entered path=%s", path) if _test_sink is not None: try: _test_sink(kind, body) diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py index 05717d9c..4ad009bb 100644 --- a/backend/apps/skill_registry/skill_registry.py +++ b/backend/apps/skill_registry/skill_registry.py @@ -166,6 +166,7 @@ async def _fetch_all_skills() -> dict[str, dict]: async def _refresh_loop(): global _cache, _cache_updated_at + logger.info("[perf] bg skill._refresh_loop entered") backoff = _RETRY_BACKOFF_START_S while True: ok = False From 3ec6b16b2619a232b9d13ebf814fc32e97da48d3 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 18:11:46 -0700 Subject: [PATCH 077/174] [eric] release: bump version to 1.3.89 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index 62610dad..b460b67a 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.88", + "version": "1.3.89", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From c577382943a54e261b1e51878593099a8ca0ee7c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 18:24:33 -0700 Subject: [PATCH 078/174] [eric] oauth: complete Claude callback server-to-server, not a cross-port browser 302 (fixes browser-dependent 'Connecting' hang from #84) --- backend/apps/agents/9router_gpt5_patch.js | 39 +++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 885efebe..f5295d54 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -39,24 +39,45 @@ const _http = require('http'); } catch (_) {} })(); -// 9Router's /callback page is a client-side relay (postMessage/BroadcastChannel/ -// localStorage) that fails when the OAuth flow runs in the user's system browser: -// no opener, different cookie jar. 302 to the backend so the exchange happens -// server-side. Idempotent via _completed_oauth (backend/apps/oauth_state.py) so -// a racing renderer-driven exchange in popup mode dedups. -(function patchOauthCallbackRedirect() { +// Claude OAuth completion. Anthropic only whitelists localhost:20128/callback as the +// redirect, so Claude's callback HAS to land here on 9Router (unlike Gemini, which goes +// straight to the backend, and Codex, which has its own :1455 listener). We previously +// 302'd the user's browser across ports to the backend, but a cross-port plain-http +// localhost redirect silently fails in browsers that HTTPS-upgrade or block it, which +// hung "Connectingโ€ฆ" for some users (browser-dependent, Claude-only). Fix: run the code +// exchange server-to-server (9Router -> backend, same machine, no browser in the loop) +// and hand the browser a static close-page. The browser only ever talks to :20128. +// Idempotent via the backend's _pending_oauth.pop + _completed_oauth. +(function patchOauthCallbackExchange() { try { const http = require('http'); const origEmit = http.Server.prototype.emit; + const closePage = + '' + + 'You can close this tab, and any other Claude login tab still open.'; http.Server.prototype.emit = function patchedEmit(event, req, res) { if (event === 'request' && req && res) { try { const url = req.url || ''; if (url.startsWith('/callback?')) { const backendPort = process.env.OPENSWARM_PORT || '8324'; - const target = 'http://localhost:' + backendPort + '/api/subscriptions/callback' + url.slice('/callback'.length); - res.writeHead(302, { Location: target }); - res.end(); + const path = '/api/subscriptions/callback' + url.slice('/callback'.length); + let done = false; + const finish = () => { + if (done) return; + done = true; + try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(closePage); } catch (_) {} + }; + try { + const proxyReq = http.request( + { host: '127.0.0.1', port: backendPort, path: path, method: 'GET' }, + (proxyRes) => { proxyRes.resume(); proxyRes.on('end', finish); } + ); + proxyReq.on('error', finish); + proxyReq.setTimeout(5000, () => { try { proxyReq.destroy(); } catch (_) {} finish(); }); + proxyReq.end(); + } catch (_) { finish(); } return true; } } catch (_) {} From ea84f578134a6fc3ef99f107e555d9514861cbb9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 18:36:11 -0700 Subject: [PATCH 079/174] [eric] release: bump to 1.3.86 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index 86330afc..e8d7f473 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.85", + "version": "1.3.86", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From 035ef26c0fe1e89591306899805346c738e5b23c Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 18:40:01 -0700 Subject: [PATCH 080/174] [eric] backend: faulthandler stack-dump + settings boot log to catch the cold loop stall - v1.3.89 cold proved two stacked stalls: ~5s in is_running() (sync httpx) and a bigger ~13s BEFORE any background task runs, a silent event-loop freeze - add faulthandler.dump_traceback_later(7s, repeat) to a temp file so a dump lands inside the 13s window and names the exact synchronous call the loop is stuck in - add an entry log to settings._boot_router_then_sync (first startup bg task) - diagnostic only; reverted once the stall is pinned --- backend/apps/settings/settings.py | 1 + backend/main.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index bd501e1f..21b747f8 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -42,6 +42,7 @@ async def settings_lifespan(): async def _boot_router_then_sync(): """Boot 9Router then push key-based connections (sequential: sync helpers no-op pre-boot).""" + logger.info("[perf] bg settings._boot_router_then_sync entered") needs_router = any([ getattr(s, "google_api_key", None), getattr(s, "openai_api_key", None), diff --git a/backend/main.py b/backend/main.py index 9aa7d69f..17c68c87 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,18 @@ if not _backend_logger.handlers: logger = logging.getLogger(__name__) +# [perf][diagnostic] Cold-start stall hunt: dump every thread's stack every 7s to +# a temp file. During the cold ~13s event-loop stall a dump lands inside the +# frozen window and names the exact synchronous call the loop is stuck in. Temp +# file only, best-effort; remove once the stall is diagnosed. +try: + import faulthandler as _faulthandler + import tempfile as _tempfile + _fh_diag = open(os.path.join(_tempfile.gettempdir(), "openswarm-faulthandler.log"), "w") + _faulthandler.dump_traceback_later(7, repeat=True, file=_fh_diag) +except Exception: + pass + from fastapi.responses import JSONResponse, HTMLResponse from fastapi import Request From 36f54aa534f3ffa9b650beac5ad5bebcac4aed44 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 18:40:02 -0700 Subject: [PATCH 081/174] [eric] release: bump version to 1.3.90 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index b460b67a..b73f5913 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.89", + "version": "1.3.90", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From 24df3e8fac289e8869571e8a2bee808c02149bcc Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 19:06:10 -0700 Subject: [PATCH 082/174] [eric] backend: fast-fail is_running() to kill the ~18s cold-start event-loop freeze - faulthandler on the signed cold build caught the asyncio loop frozen in socket.create_connection inside is_running() (process.py): a synchronous httpx.get to "localhost:20128" called ~5x on the boot path before 9Router is up - on Windows a dead-port connect to "localhost" stalls ~7s each (tries ::1 first, loopback refusal is slow), freezing the loop ~18s so uvicorn could not answer the health probe -> cold backend-http-ready was ~23s - fix: probe 127.0.0.1 with a 0.3s TCP timeout first (measured 306ms vs ~7s), only HTTP-confirm when the port is open; 9Router binds 0.0.0.0 so reachability is unchanged. drop the faulthandler diagnostic from main.py --- backend/apps/nine_router/process.py | 21 +++++++++++++++++++-- backend/main.py | 12 ------------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index cdaf66b8..912b2d0b 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -16,6 +16,7 @@ import logging import os import secrets import shutil +import socket import subprocess import tempfile import time @@ -74,13 +75,29 @@ _is_running_last_ok: float = 0.0 def is_running() -> bool: - """Check if 9Router is running.""" + """Check if 9Router is running. + + Fast-fail when down. is_running() is called ~5x on the cold boot path (the + settings key-sync sequence + ensure_running) BEFORE 9Router is up. The old + body did a synchronous httpx.get to "localhost:20128"; on Windows a dead-port + connect to "localhost" stalls multiple seconds (it tries ::1 first and the + loopback refusal is slow), so those probes froze the asyncio event loop ~18s + and dominated cold startup (faulthandler caught the loop stuck in + socket.create_connection here). Fix: probe 127.0.0.1 with a 0.3s TCP timeout + first; a down 9Router is detected in <~0.3s instead of ~7s. Only when the + port is open do we do the HTTP confirm. 9Router binds 0.0.0.0 (the warm app + reaches it via 127.0.0.1 today), so this changes timing, not reachability.""" global _is_running_last_ok now = time.monotonic() if now - _is_running_last_ok < _IS_RUNNING_TTL: return True try: - r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0) + with socket.create_connection(("127.0.0.1", NINE_ROUTER_PORT), timeout=0.3): + pass + except OSError: + return False + try: + r = httpx.get(f"http://127.0.0.1:{NINE_ROUTER_PORT}/v1/models", timeout=2.0) if r.status_code == 200: _is_running_last_ok = now return True diff --git a/backend/main.py b/backend/main.py index 17c68c87..9aa7d69f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,18 +18,6 @@ if not _backend_logger.handlers: logger = logging.getLogger(__name__) -# [perf][diagnostic] Cold-start stall hunt: dump every thread's stack every 7s to -# a temp file. During the cold ~13s event-loop stall a dump lands inside the -# frozen window and names the exact synchronous call the loop is stuck in. Temp -# file only, best-effort; remove once the stall is diagnosed. -try: - import faulthandler as _faulthandler - import tempfile as _tempfile - _fh_diag = open(os.path.join(_tempfile.gettempdir(), "openswarm-faulthandler.log"), "w") - _faulthandler.dump_traceback_later(7, repeat=True, file=_fh_diag) -except Exception: - pass - from fastapi.responses import JSONResponse, HTMLResponse from fastapi import Request From f2c9c256032c7d1ccc926a853683567da89fd46c Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 19:06:10 -0700 Subject: [PATCH 083/174] [eric] release: bump version to 1.3.91 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index b73f5913..19bbc3df 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.90", + "version": "1.3.91", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From ec0f9600bc9c3f1a4376edde84f6eefb33c2b7b8 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 19:28:23 -0700 Subject: [PATCH 084/174] [eric] backend: remove cold-start diagnostics, keep the fix + slim lifespan timer - the is_running() fast-fail fix (v1.3.91) verified cold backend-http-ready 23.5s -> 3.86s and warm 5.0s -> 3.32s on the signed build, no 9Router regression - strip the [perf] bg entry logs from mcp/skill/settings/service/9router now that the stall is diagnosed; keep Apps.py per-lifespan timing but only print a lifespan over 50ms plus the total (cheap regression tripwire) - document the root cause + fix + before/after in docs/perf/winv2/README.md --- backend/apps/mcp_registry/mcp_registry.py | 1 - backend/apps/nine_router/process.py | 5 -- backend/apps/service/client.py | 4 -- backend/apps/settings/settings.py | 1 - backend/apps/skill_registry/skill_registry.py | 1 - backend/config/Apps.py | 4 +- docs/perf/winv2/README.md | 48 +++++++++++++++++++ 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/backend/apps/mcp_registry/mcp_registry.py b/backend/apps/mcp_registry/mcp_registry.py index eb24b03e..4a8bdeac 100644 --- a/backend/apps/mcp_registry/mcp_registry.py +++ b/backend/apps/mcp_registry/mcp_registry.py @@ -292,7 +292,6 @@ def _apply_stars(servers: dict[str, dict]): async def _refresh_loop(): """Background loop that refreshes the cache on startup and then hourly.""" global _cache, _cache_updated_at - logger.info("[perf] bg mcp._refresh_loop entered") while True: try: community, google = await asyncio.gather( diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index 912b2d0b..cae6878e 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -359,11 +359,9 @@ async def ensure_running(): """Start 9Router if not already running. Serialized so concurrent callers (the background auto-start + a dispatch-time ensure) can't double-spawn.""" global _start_lock - logger.info("[perf] bg 9r.ensure_running entered") if _start_lock is None: _start_lock = asyncio.Lock() async with _start_lock: - logger.info("[perf] bg 9r.ensure_running past lock") await _ensure_running_impl() @@ -395,10 +393,8 @@ async def _ensure_running_impl(): else: logger.info("9Router already running on port %d", NINE_ROUTER_PORT) return - logger.info("[perf] bg 9r past is_running()") _9router_dir = _find_9router_dir() _patch = _gpt5_patch_path() - logger.info("[perf] bg 9r found dir+patch") if _is_packaged: # Packaged: run the pre-built standalone server staged at @@ -414,7 +410,6 @@ async def _ensure_running_impl(): if not os.path.exists(standalone_server): _report_start_failure("server_missing", router_dir_found=True) return - logger.info("[perf] bg 9r pre find_node") node = _find_node() if not node: _report_start_failure("node_not_found", router_dir_found=True, server_found=True) diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index 2131baca..a835d8f1 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -194,12 +194,9 @@ def _base_url() -> str: async def _post(path: str, body: dict) -> int | None: url = f"{_base_url()}{path}" - logger.info("[perf] bg svc._post client-create %s", path) try: async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as c: - logger.info("[perf] bg svc._post sending %s", path) r = await c.post(url, json=body) - logger.info("[perf] bg svc._post done %s", path) return r.status_code except Exception as e: logger.debug("service POST %s failed: %s", path, e) @@ -217,7 +214,6 @@ def _retryable(status: int | None) -> bool: async def _post_or_spool(path: str, body: dict, kind: str) -> None: global _inflight - logger.info("[perf] bg svc._post_or_spool entered path=%s", path) if _test_sink is not None: try: _test_sink(kind, body) diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 21b747f8..bd501e1f 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -42,7 +42,6 @@ async def settings_lifespan(): async def _boot_router_then_sync(): """Boot 9Router then push key-based connections (sequential: sync helpers no-op pre-boot).""" - logger.info("[perf] bg settings._boot_router_then_sync entered") needs_router = any([ getattr(s, "google_api_key", None), getattr(s, "openai_api_key", None), diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py index 4ad009bb..05717d9c 100644 --- a/backend/apps/skill_registry/skill_registry.py +++ b/backend/apps/skill_registry/skill_registry.py @@ -166,7 +166,6 @@ async def _fetch_all_skills() -> dict[str, dict]: async def _refresh_loop(): global _cache, _cache_updated_at - logger.info("[perf] bg skill._refresh_loop entered") backoff = _RETRY_BACKOFF_START_S while True: ok = False diff --git a/backend/config/Apps.py b/backend/config/Apps.py index 3ab5e12c..7bd25b4b 100644 --- a/backend/config/Apps.py +++ b/backend/config/Apps.py @@ -40,7 +40,9 @@ class MainApp: debug(sub_app.name) _t0 = time.perf_counter() await stack.enter_async_context(sub_app.lifespan()) - print(f"[perf] lifespan {sub_app.name} t={(time.perf_counter() - _t0) * 1000:.0f}ms", flush=True) + _dt = (time.perf_counter() - _t0) * 1000 + if _dt > 50: # only flag a slow lifespan; keeps boot logs quiet + print(f"[perf] lifespan {sub_app.name} t={_dt:.0f}ms", flush=True) print(f"[perf] lifespans-total t={(time.perf_counter() - _boot_t0) * 1000:.0f}ms", flush=True) _port = os.environ.get("OPENSWARM_PORT", "8324") print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n") diff --git a/docs/perf/winv2/README.md b/docs/perf/winv2/README.md index ca18981d..2ae0da3b 100644 --- a/docs/perf/winv2/README.md +++ b/docs/perf/winv2/README.md @@ -344,3 +344,51 @@ Status: warm 5.0s (under goal), cold ~22s (75-84% below the 54-138s baseline), b bugs fixed/verified on the signed build. The cold residual is either accepted as first-run-only OS I/O, or pinned definitively by one more build that ships this instrumentation. Build-gated (user manages tags/release), so not auto-built. + +## [SOLVED 2026-06-18] cold ~22s -> 3.86s: synchronous is_running() froze the event loop + +The per-lifespan instrumentation (v1.3.88) overturned every prior hypothesis: all +16 lifespans enter in ~120ms even COLD. The ~18s cold cost was entirely AFTER +lifespan startup, in a backgrounded create_task that synchronously blocked the +single asyncio event loop, so uvicorn could not answer the health probe. + +Finer instrumentation (v1.3.89) split it into two stalls (~13s before any bg task, +~5s in 9Router ensure). faulthandler (`dump_traceback_later`, v1.3.90) on the +signed cold build caught the loop thread frozen, three times, in the SAME call: + +``` +socket.create_connection <- stuck >7s +httpx ... get +backend/apps/nine_router/process.py:83 is_running() <- synchronous httpx.get + <- sync_openswarm_pro_as_claude / sync_custom_providers (settings._boot_router_then_sync) + <- _ensure_running_impl (ensure_running) +``` + +ROOT CAUSE: `is_running()` did a synchronous `httpx.get("http://localhost:20128/...")`. +It is called ~5x on the cold boot path (the settings key-sync sequence + the +9Router ensure) BEFORE 9Router is up. On Windows a dead-port connect to +"localhost" stalls ~7s each: getaddrinfo returns `::1` first, and the loopback +refusal is slow (measured: a refused connect is ~2s/address, and localhost = +`::1`+`127.0.0.1` = ~4s; cold ~7s). ~5 serial probes = the ~18s freeze. + +This is why every earlier hypothesis missed: it is not disk, not Defender, not +file-count, not the DEBUGLETON scan, not imports, not the lifespans. It is one +synchronous network probe on the event loop, repeated. + +FIX (v1.3.91, `process.py` is_running): probe `127.0.0.1` with a 0.3s TCP timeout +first (a short timeout caps the slow Windows refusal: measured 306ms vs ~7s); only +HTTP-confirm when the port is open. 9Router binds `0.0.0.0` (the warm app reaches +it via `127.0.0.1`), so reachability is unchanged, only the dead-port wait dies. + +VERIFIED on the real signed build (this Windows 11 box, fresh Squirrel install): + +| metric | baseline | before fix (1.3.90) | after fix (1.3.91) | +| --- | --- | --- | --- | +| cold backend-http-ready | 54-138s | 23.5s | **3.86s** | +| warm backend-http-ready | 9-10s | 5.0s | **3.32s** | + +Cold is now ~97% below baseline and well under the 10s goal; warm improved too +(the same localhost stall taxed it). 9Router still starts successfully via the new +probe (no regression). The diagnostic `[perf] bg` logs + faulthandler were removed +after diagnosis; the lightweight per-lifespan timer stays (prints only a lifespan +over 50ms + the total) as a cheap regression tripwire. From 666c53e8880dbba0753e52061cdc27f9730e2468 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 17 Jun 2026 19:28:23 -0700 Subject: [PATCH 085/174] [eric] release: bump version to 1.3.92 --- electron/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/package.json b/electron/package.json index 19bbc3df..92d616da 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.3.91", + "version": "1.3.92", "description": "OpenSwarm โ€” AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", From a18111ac8ac24ac163bca71f55b12ffe1350530d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 20:12:56 -0700 Subject: [PATCH 086/174] [eric] electron: red traffic-light quits the app instead of hiding; neuter Cmd+W --- electron/main.js | 92 +++++++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/electron/main.js b/electron/main.js index 4357f909..e5d2ff6c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1269,40 +1269,27 @@ function createWindow() { // closing windows as part of its pipeline. mainWindow.on('close', (e) => { console.log(`[diag][main] mainWindow close (quitInitiated=${quitInitiated})`); - // macOS close-to-dock: a close that is not part of a real quit (Cmd+Q, - // dock Quit, logout, updater โ€” all fire before-quit first, flipping - // quitInitiated) gets prevented and the window HIDES instead. This keeps - // the renderer, webviews, and running agents fully alive, so both the - // user's Cmd+W/red-X and the un-attributed programmatic closer behind - // the 1.2.77 self-quits cost nothing: the next dock click shows the - // same window back instantly (`activate` below). Real quits must pass - // through โ€” preventing a close during app.quit() cancels the quit - // (Electron semantics), which is exactly what the flag guards against. - // isInstallingUpdate must pass through: native quitAndInstall TERMINATES the app - // by closing the window (with quitInitiated still false), so intercepting it here - // hides the window and strands the update uninstalled. That was THE bug behind - // "Restart & Update does nothing" on Mac. Let that close (and real quits) through. + // macOS: the only way to land here with quitInitiated still false is the red + // traffic-light button. Cmd+W is swallowed in before-input-event, renderer + // window.close() is neutered above, and crash-recovery uses destroy() (which + // skips 'close'). So a red-button click means "quit": route it through + // app.quit() so before-quit drains the App Builder subprocesses and will-quit + // kills the backend, instead of leaving a headless app running. Real quits + // (Cmd+Q, dock Quit, logout) flip quitInitiated via before-quit first and pass + // straight through. isInstallingUpdate must also pass through: native + // quitAndInstall closes the window with quitInitiated still false, and + // intercepting it strands the update (THE "Restart & Update does nothing" bug). if (process.platform === 'darwin' && !quitInitiated && !isInstallingUpdate) { e.preventDefault(); - // A staged update waiting + a user close = "apply it on the way out". Kick off - // the install (arms ShipIt + drives a real quit) instead of just hiding, so the - // red button finally updates instead of looping. + // A staged update waiting + a user close = "apply it on the way out": the + // install arms ShipIt and drives its own quit, so update instead of quitting. if (cachedUpdateStatus && cachedUpdateStatus.status === 'downloaded') { console.log('[updater] close with a staged update; applying it'); installDownloadedUpdate(); return; } - try { - if (thisWindow.isFullScreen()) { - // Hiding a fullscreen window strands a black space; leave - // fullscreen first, then hide once the transition lands. - thisWindow.once('leave-full-screen', () => { try { thisWindow.hide(); } catch (_) {} }); - thisWindow.setFullScreen(false); - } else { - thisWindow.hide(); - } - console.log('[diag][main] close intercepted, window hidden (app + agents stay alive)'); - } catch (_) {} + console.log('[diag][main] red-button close, quitting app'); + app.quit(); } }); mainWindow.on('closed', () => { @@ -1924,7 +1911,34 @@ app.whenReady().then(async () => { } }); +// Cmd+W is the default menu's "File > Close Window". Now that the red button +// routes a close into app.quit(), an unguarded Cmd+W would tear down the whole +// app + every running agent on a stray tab-close reflex (the exact 1.2.77 +// self-quit class). preventDefault here also blocks the menu accelerator +// (electron/electron#19279), and because macOS dispatches that accelerator +// against whichever webContents is focused, we have to guard the main window AND +// its webview guests, not just one. mac-only; on Windows Ctrl+W is input.control +// so this no-ops there and leaves that platform's close-on-last-window intact. +function swallowCloseWindowShortcut(event, input) { + if ( + input.type === 'keyDown' && + process.platform === 'darwin' && + input.meta && !input.control && !input.alt && + (input.key || '').toLowerCase() === 'w' + ) { + event.preventDefault(); + } +} + app.on('web-contents-created', (_event, contents) => { + // Block Cmd+W from closing the main window, whether the window chrome or one of + // its embedded webviews has focus. OAuth popups (their own 'window' contents, + // created while isCreatingMainWindow is false) are left alone so the user can + // still Cmd+W them shut. + if (isCreatingMainWindow || contents.getType() === 'webview') { + contents.on('before-input-event', swallowCloseWindowShortcut); + } + // Override the user-agent on popup BrowserWindows (i.e. anything created // via window.open from the renderer, which includes the OAuth popup for // subscription connect flows). Electron's default UA includes an @@ -2199,16 +2213,13 @@ app.on('web-contents-created', (_event, contents) => { app.on('window-all-closed', () => { console.log(`[diag][main] window-all-closed (platform=${process.platform}${process.platform === 'darwin' ? ', staying alive' : ', quitting'})`); - // macOS: stay alive like a standard Mac app. We never install a custom - // application menu, so Electron's DEFAULT menu ships File > Close Window - // (Cmd+W) โ€” and with a single window, quitting here turned "close the - // window" into "tear down the backend and every running agent". The 1.2.77 - // prod self-quits all carried this exact signature (window close with no - // preceding before-quit). Keeping the process alive de-fangs the whole - // class: the dock icon stays, `activate` below reopens against the warm - // backend in ~1s, and the [diag][main] close-cause logging identifies the - // closer. Explicit quits (Cmd+Q, dock Quit) are untouched โ€” Electron's - // quit pipeline runs will-quit -> killBackend regardless of this handler. + // macOS: don't quit just because the window list hit zero. The red button now + // routes through app.quit() (which drives will-quit -> killBackend itself) and + // Cmd+W is swallowed, so the only window-vanish that ISN'T already a real quit + // is an unforeseen teardown (a renderer-level destroy that skipped 'close'). For + // that stray case we stay alive as a standard Mac app rather than self-quitting + // headless, and `activate` below rebuilds the window on the next dock click. The + // 1.2.77 self-quits lived exactly here (window close with no before-quit). if (process.platform === 'darwin') { // An update install closed the window (native quitAndInstall) and now needs the // process to actually die so ShipIt can swap + relaunch; finish the quit instead @@ -2288,9 +2299,10 @@ app.on('will-quit', () => { }); app.on('activate', () => { - // Dock-click after close-to-dock: the common case is a HIDDEN (not - // destroyed) window โ€” just show it again; renderer, webviews, and agents - // never stopped, so this is instant and lossless. + // Live window still around (minimized, or hidden by some stray path): surface + // it instead of building a new one. The red button quits now, so the usual + // dock-click-after-close lands in the destroyed-window fallback below; this + // branch is the cheap, lossless path for the cases where a window survived. if (mainWindow && !mainWindow.isDestroyed()) { try { if (mainWindow.isMinimized()) { From fdfe014ae04986f4013abbb432ac17b1c0319d04 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 23:13:01 -0700 Subject: [PATCH 087/174] [eric] dashboard: select/drag a browser card from its whole body, not just the header --- .../src/app/pages/Dashboard/cards/BrowserCard.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 7fce9c06..22e0b17c 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1118,6 +1118,21 @@ const BrowserCard: React.FC = ({ {/* Browser body: stacked webviews */} + {/* Select-then-interact: the body is a live webview that swallows clicks, so + only the header used to select/drag the card. While the card is unselected + (and not cmd-panning or element-picking) lay a transparent catcher over the + body: a plain click bubbles to the card onClick (select), a drag runs the + same move handler as the header. It lifts the instant the card is selected, + so the page goes live again. Sits above the webview but below the end/crash + pills (z5/z6) and the agent overlay (z16) so it never steals their clicks. */} + {!isSelected && !cmdHeld && !isElementSelectMode && ( + + )} {isElementSelectMode && ( )} From 8b63e3d7c6b9c7435db6dd18905811716c003d26 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 23:18:49 -0700 Subject: [PATCH 088/174] [eric] settings: add reset-to-defaults endpoint (preferences to defaults, keep connections + sign-in) --- backend/apps/settings/settings.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 0167dad6..b16fa1a2 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -306,6 +306,33 @@ async def reset_system_prompt(): return {"ok": True, "settings": current.model_dump()} +# A preferences reset (the iOS "Reset All Settings" analogue): everything back to +# defaults EXCEPT the things a "reset my preferences" click must never silently +# sever, your connections (server-owned subscription fields AND your pasted +# provider credentials) and your identity. Hard-erase is the separate flow. +_RESET_PRESERVE_FIELDS = SERVER_OWNED_FIELDS + ( + "anthropic_api_key", + "openai_api_key", + "google_api_key", + "openrouter_api_key", + "custom_providers", + "user_name", + "user_email", + "analytics_opt_in", + "first_opened_at", +) + + +@settings.router.post("/reset-to-defaults") +async def reset_to_defaults(): + old = load_settings() + fresh = AppSettings() + for k in _RESET_PRESERVE_FIELDS: + setattr(fresh, k, getattr(old, k, None)) + await save_settings_async(fresh) + return {"ok": True, "settings": fresh.model_dump()} + + class BrowseResponse(BaseModel): current: str parent: Optional[str] From 6d9e5598922180916880f23053b30007390b86e0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 23:18:49 -0700 Subject: [PATCH 089/174] [eric] settings: Data & Privacy section, Reset all settings + Erase all content (Apple-minimal, typed-confirm) --- .../sections/general/DataPrivacySection.tsx | 135 ++++++++++++++++++ .../Settings/sections/general/GeneralTab.tsx | 3 + frontend/src/types/electron.d.ts | 1 + 3 files changed, 139 insertions(+) create mode 100644 frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx diff --git a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx new file mode 100644 index 00000000..5017785d --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import TextField from '@mui/material/TextField'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { API_BASE } from '@/shared/config'; +import type { SettingsStyles } from '../settingsStyles'; + +const ERASE_WORD = 'ERASE'; + +// The iOS Reset menu, two actions only: "Reset All Settings" (preferences back to +// defaults, your stuff + sign-in stay) and "Erase All Content and Settings" (factory +// wipe + relaunch). Flat rows, not a boxed "danger zone": red lives only on the +// destructive label, and the real friction is the typed-confirm in the dialog. +const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => { + const c = useClaudeTokens(); + const { sectionSx, labelSx, descSx } = styles; + + const [resetOpen, setResetOpen] = useState(false); + const [eraseOpen, setEraseOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [eraseText, setEraseText] = useState(''); + const [err, setErr] = useState(null); + + const closeAll = () => { + if (busy) return; + setResetOpen(false); + setEraseOpen(false); + setEraseText(''); + setErr(null); + }; + + const doReset = async () => { + setBusy(true); + setErr(null); + try { + const res = await fetch(`${API_BASE}/settings/reset-to-defaults`, { method: 'POST' }); + if (!res.ok) throw new Error(String(res.status)); + // Reload so every slice + local component state re-syncs from the now-default + // backend; no stale flag can survive a full renderer reload. + window.location.reload(); + } catch { + setBusy(false); + setErr("Couldn't reset just now. Try again in a moment."); + } + }; + + const doErase = async () => { + const api = window.openswarm; + if (!api?.hardReset) { + setErr('This only works in the desktop app.'); + return; + } + setBusy(true); + setErr(null); + try { + await api.hardReset(); // the app exits + relaunches, so this normally never resolves. + } catch { + setBusy(false); + setErr("Couldn't erase just now. Try again in a moment."); + } + }; + + const dialogPaperSx = { + bgcolor: c.bg.surface, + border: `1px solid ${c.border.subtle}`, + borderRadius: 2.5, + maxWidth: 360, + }; + const titleSx = { color: c.text.primary, fontSize: '0.95rem', fontWeight: 600, mb: 1 }; + const bodySx = { color: c.text.secondary, fontSize: '0.8rem', lineHeight: 1.5, mb: 2 }; + const errSx = { color: c.status.error, fontSize: '0.75rem', mb: 1.5 }; + const cancelSx = { color: c.text.secondary, textTransform: 'none', fontWeight: 500 }; + const actionRowSx = { display: 'flex', justifyContent: 'flex-end', gap: 1 }; + + return ( + + Data & Privacy + + + + Reset all settings + Puts your preferences back to defaults. Your apps, chats, skills, and sign-in stay. + + + + + + + Erase all content and settings + Removes every chat, app, skill, and setting and restarts OpenSwarm fresh. This can't be undone. + + + + + + + Reset all settings? + Your preferences go back to defaults. Apps, chats, skills, and sign-in stay. + {err && {err}} + + + + + + + + + + Erase all content and settings? + This deletes every chat, app, skill, and setting, then restarts OpenSwarm. It can't be undone. + setEraseText(e.target.value)} + placeholder={`Type ${ERASE_WORD} to confirm`} + fullWidth + size="small" + autoFocus + disabled={busy} + sx={{ mb: 2, '& .MuiOutlinedInput-root': { fontSize: '0.8rem' } }} + /> + {err && {err}} + + + + + + + + ); +}; + +export default DataPrivacySection; diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx index 0535cc19..4640c64e 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx @@ -6,6 +6,7 @@ import AccountCard from '../subscription/AccountCard'; import GeneralAgentDefaults from './GeneralAgentDefaults'; import GeneralInterface from './GeneralInterface'; import GeneralAdvanced from './GeneralAdvanced'; +import DataPrivacySection from './DataPrivacySection'; import type { SettingsStyles } from '../settingsStyles'; type ModelOption = { value: string; label: string }; @@ -42,6 +43,8 @@ const GeneralTab: React.FC<{ + + ); }; diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 8145bccc..2f416e7b 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -48,6 +48,7 @@ declare global { onUpdateError: (cb: (message: string) => void) => () => void; onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void; openExternal: (url: string) => Promise; + hardReset?: () => Promise; onAuthUrl?: (cb: (url: string) => void) => () => void; onOauthClaim?: (cb: (url: string) => void) => () => void; } From 3f9fde17a8319dbdf2398d43d49559ced1ea1dc2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 17 Jun 2026 23:32:17 -0700 Subject: [PATCH 090/174] [eric] electron: hard-reset IPC (kill backend, wipe data dir, relaunch) for factory reset --- electron/main.js | 19 +++++++++++++++++++ electron/preload.js | 2 ++ 2 files changed, 21 insertions(+) diff --git a/electron/main.js b/electron/main.js index e5d2ff6c..56d45f63 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2561,6 +2561,25 @@ ipcMain.handle('get-install-state', () => { } }); +// Factory reset ("Erase all content and settings"). Stop the backend FIRST so +// nothing rewrites the dir mid-wipe (on Windows a live process even locks the +// files), wipe everything under userData/data, then relaunch into a clean first +// run. install.json lives OUTSIDE /data so the install + affiliate identity +// survives, exactly like a real reinstall would. Best-effort throughout: a +// failed kill or wipe still relaunches rather than wedging the user. +ipcMain.handle('hard-reset', async () => { + try { killBackend(); } catch (e) { console.error('[hard-reset] killBackend failed', e); } + try { + const dataDir = path.join(app.getPath('userData'), 'data'); + fs.rmSync(dataDir, { recursive: true, force: true }); + console.log('[hard-reset] wiped data dir'); + } catch (e) { + console.error('[hard-reset] wipe failed', e); + } + app.relaunch(); + app.exit(0); +}); + // --------------------------------------------------------------------------- // CDP debugger bridge for the browser sub-agent // --------------------------------------------------------------------------- diff --git a/electron/preload.js b/electron/preload.js index e7e70ab2..ac4a3e13 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -55,6 +55,8 @@ contextBridge.exposeInMainWorld('openswarm', { // Renderer attaches the ref to Stripe checkout + sign-in flows so // the cloud can credit the affiliate. Resolves to {} if no state yet. getInstallState: () => ipcRenderer.invoke('get-install-state'), + // Factory reset: wipes the data dir and relaunches. Never resolves on success (the app exits first). + hardReset: () => ipcRenderer.invoke('hard-reset'), connectSlack: () => ipcRenderer.invoke('connect-slack'), sendCdpCommand: (wcId, method, params, sessionId) => ipcRenderer.invoke('send-cdp-command', wcId, method, params, sessionId), cdpCacheSet: (wcId, indexMap) => ipcRenderer.invoke('cdp-cache-set', wcId, indexMap), From e0e2bb9f3cfb29b8017ab9cb4fc6648b01ff68ed Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 18 Jun 2026 00:21:55 -0700 Subject: [PATCH 091/174] [eric] settings: Data & Privacy buttons match the About outlined style (Restart tour), drop the ellipsis + add section spacing --- .../sections/general/DataPrivacySection.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx index 5017785d..8c0c7335 100644 --- a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx +++ b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx @@ -75,24 +75,41 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => const cancelSx = { color: c.text.secondary, textTransform: 'none', fontWeight: 500 }; const actionRowSx = { display: 'flex', justifyContent: 'flex-end', gap: 1 }; + // Match the About-section outlined buttons (Restart tour / Check for Updates). + const rowBtnSx = { + color: c.text.secondary, + borderColor: c.border.medium, + textTransform: 'none' as const, + fontSize: '0.8rem', + whiteSpace: 'nowrap' as const, + '&:hover': { color: c.accent.primary, borderColor: c.accent.primary }, + }; + const eraseBtnSx = { + ...rowBtnSx, + color: c.status.error, + borderColor: c.status.error, + '&:hover': { color: c.status.error, borderColor: c.status.error, bgcolor: c.status.errorBg }, + }; + const rowSx = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 3, py: 2 }; + return ( - Data & Privacy + Data & Privacy - + Reset all settings Puts your preferences back to defaults. Your apps, chats, skills, and sign-in stay. - + - + Erase all content and settings Removes every chat, app, skill, and setting and restarts OpenSwarm fresh. This can't be undone. - + From de173a6a1ca6d3c76be2d6e350c87c61b0c5757b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 18 Jun 2026 01:55:40 -0700 Subject: [PATCH 092/174] [eric] dashboard: body clicks select an unselected browser (webview passes them through) --- frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 22e0b17c..bf0238e1 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1199,6 +1199,10 @@ const BrowserCard: React.FC = ({ border: 'none', visibility: tab.id === activeTabId ? 'visible' : 'hidden', zIndex: tab.id === activeTabId ? 1 : 0, + // Unselected: ignore host clicks so the select-catcher above actually gets + // them (a webview paints over plain DOM and would swallow them); the agent + // drives the page over CDP, which bypasses this, so agents are unaffected. + pointerEvents: isSelected ? 'auto' : 'none', }} /> ))} @@ -1320,7 +1324,7 @@ const BrowserCard: React.FC = ({