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 (_) {}
diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index 5c97c8d4..a8338b0a 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -16,6 +16,7 @@ from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.settings.settings import load_settings
from backend.apps.tools_lib.tools_lib import (
_load_all as load_all_tools,
+ _save as save_tool,
_sanitize_server_name,
derive_mcp_config,
load_builtin_permissions,
@@ -23,6 +24,8 @@ from backend.apps.tools_lib.tools_lib import (
refresh_airtable_token,
refresh_google_token,
refresh_hubspot_token,
+ resolve_policy_slot,
+ save_builtin_permissions,
save_trusted_sensitive_paths,
)
from backend.config.paths import SESSIONS_DIR
@@ -36,6 +39,8 @@ from backend.apps.agents.core.error_classify import (
p_is_transient_capacity_error,
p_is_unknown_model_error,
p_extract_reset_hint,
+ parse_retry_after,
+ redact_for_telemetry,
)
from backend.apps.agents.manager.session.session_store import (
_delete_session_file,
@@ -62,12 +67,15 @@ from backend.apps.agents.manager.session.history_compaction import (
from backend.apps.agents.manager.prompt.prompt_context import (
_build_browser_context,
_build_selected_app_context,
+ _build_selected_settings_context,
_build_connected_tools_context,
_build_mcp_registry_summary,
_compose_system_prompt,
_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,
@@ -134,6 +142,11 @@ def get_workflow_step_usage(session_id: str) -> dict[str, dict[str, bool]]:
return mem.step_usage if mem is not None else {}
+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).
@@ -187,7 +200,11 @@ class AgentManager:
def __init__(self):
self.sessions: dict[str, AgentSession] = {}
self.tasks: dict[str, asyncio.Task] = {}
-
+ # Live mirror of the in-flight streamed assistant text per session, so a
+ # stop can persist the partial reply instantly instead of waiting out the
+ # multi-second SDK teardown the cancel handler sits behind.
+ self._live_partial: dict[str, dict] = {}
+
def _resolve_mode(self, mode_id: str) -> tuple[list[str], str | None, str | None]:
return _resolve_mode(mode_id, get_all_tool_names)
@@ -242,8 +259,9 @@ class AgentManager:
continue
if tool.auth_type == "oauth2" and tool.auth_status == "connected":
- if tool.name.lower() == "discord":
- # Discord uses a shared bot token from .env, not user OAuth tokens.
+ if tool.name.lower() in ("discord", "github"):
+ # Discord uses a shared bot token; GitHub OAuth-app tokens don't
+ # expire and carry no refresh_token. Nothing to refresh either way.
refreshed = True
elif tool.name.lower() == "airtable":
refreshed = await refresh_airtable_token(tool)
@@ -264,6 +282,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)
@@ -471,7 +512,7 @@ class AgentManager:
def _resolve_context_paths(self, context_paths: list | None) -> str:
return _resolve_context_paths(context_paths)
- async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None, selected_app_output_ids: list[str] | None = None):
+ async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None, selected_app_output_ids: list[str] | None = None, selected_setting_ids: list[str] | None = None):
"""Run the Claude Agent SDK query loop for a session."""
session = self.sessions.get(session_id)
if not session:
@@ -758,30 +799,39 @@ class AgentManager:
return policy, None
def _get_effective_policy(tool_name: str) -> str:
- """Return 'always_allow', 'deny', or 'ask' for any tool."""
- if tool_name in _builtin_perms:
- return _builtin_perms[tool_name]
-
- import re as _re
-
- bm = _re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name)
- if bm:
- return _builtin_perms.get(bm.group(1), _default_for(bm.group(1)))
-
- im = _re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name)
- if im:
- return _builtin_perms.get(im.group(1), _default_for(im.group(1)))
-
- m = _re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
- if m:
- server_slug, mcp_tool_name = m.group(1), m.group(2)
- for t in load_all_tools():
- if not t.mcp_config or not t.enabled:
- continue
- if _sanitize_server_name(t.name) == server_slug:
- return t.tool_permissions.get(mcp_tool_name, "ask")
+ """Return 'always_allow', 'deny', or 'ask' for any tool. Keyed through
+ the shared resolver so the read slot matches the write slot exactly."""
+ tools = load_all_tools()
+ slot = resolve_policy_slot(tool_name, tools)
+ if slot.store == "builtin":
+ return _builtin_perms.get(slot.key, _default_for(slot.key))
+ if slot.key is not None:
+ for t in tools:
+ if t.id == slot.key:
+ return t.tool_permissions.get(slot.action, "ask")
return _default_for(tool_name)
+ def _set_tool_policy(tool_name: str, policy: str) -> None:
+ """Inverse of _get_effective_policy: persist `policy` into the SAME slot
+ the gate reads, AND update the live in-memory snapshot, so an 'Always
+ approve' takes effect for this running agent, not only after a restart.
+ (The old code wrote the raw tool name to the file and never touched the
+ captured _builtin_perms, so it behaved like a one-time accept.)"""
+ tools = load_all_tools()
+ slot = resolve_policy_slot(tool_name, tools)
+ if slot.store == "builtin":
+ _builtin_perms[slot.key] = policy
+ perms = load_builtin_permissions()
+ perms[slot.key] = policy
+ save_builtin_permissions(perms)
+ return
+ if slot.key is not None:
+ for t in tools:
+ if t.id == slot.key:
+ t.tool_permissions[slot.action] = policy
+ save_tool(t)
+ return
+
async def _request_user_approval(
tool_name: str,
tool_input,
@@ -839,6 +889,15 @@ 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:
+ _set_tool_policy(tool_name, "always_allow")
+ 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
@@ -945,11 +1004,84 @@ 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}
+ # 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", "")
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:
+ _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,
+ "permissionDecision": "deny",
+ "permissionDecisionReason": _reason,
+ }
+ }
+ 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", {})
if _is_claude_schedule_skill(tool_name, tool_input):
@@ -1072,26 +1204,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:
@@ -1131,6 +1275,9 @@ class AgentManager:
usage = raw_response.get("usage", {})
if isinstance(usage, dict):
sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0)
+ # Pill-only lane: NEW (uncached) input, excludes the cached
+ # static prefix so the bubble shows what this turn added.
+ sub_tokens["input_fresh"] = usage.get("input_tokens", 0)
sub_tokens["output"] = usage.get("output_tokens", 0)
if raw_response.get("total_cost_usd"):
sub_cost = raw_response["total_cost_usd"]
@@ -1333,6 +1480,12 @@ class AgentManager:
if app_ctx:
composed_prompt = f"{composed_prompt}\n\n{app_ctx}" if composed_prompt else app_ctx
+ # The user can point the agent at specific Settings rows. Targeting
+ # aid only; the settings tools are always on regardless.
+ settings_ctx = _build_selected_settings_context(selected_setting_ids)
+ if settings_ctx:
+ composed_prompt = f"{composed_prompt}\n\n{settings_ctx}" if composed_prompt else settings_ctx
+
# Per-turn estimate of framework overhead (subtracted from displayed
# input). Conservative on purpose so honest over-shows beat lies.
# 16K Claude Code preset, 12K base+deferred tools, ~3K/MCP (real
@@ -1451,6 +1604,27 @@ class AgentManager:
"type": "stdio",
}
+ # Always-on settings-meta server: SettingsRead / SettingsWrite let the
+ # agent read and edit its own OpenSwarm Settings autonomously. The
+ # backend (/api/settings-meta) enforces the only two guardrails: it
+ # can't disconnect the credential powering this run, and reads come
+ # back with secrets redacted. No activation gate, Settings is the
+ # agent's own house, not a third-party MCP.
+ settings_meta_server_path = os.path.join(
+ os.path.dirname(__file__), "settings_meta_server.py"
+ )
+ from backend.auth import get_auth_token as _get_auth_token4
+ mcp_servers["openswarm-settings-meta"] = {
+ "command": sys.executable,
+ "args": [settings_meta_server_path],
+ "env": {
+ "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
+ "OPENSWARM_AUTH_TOKEN": _get_auth_token4(),
+ "OPENSWARM_PARENT_SESSION_ID": session.id,
+ },
+ "type": "stdio",
+ }
+
# The CLI's built-in WebSearch/WebFetch wraps Anthropic's
# web_search_20250305. For non-Claude primaries the CLI
@@ -1695,6 +1869,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
@@ -1710,6 +1937,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,
@@ -2041,6 +2269,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}")
@@ -2171,6 +2408,11 @@ class AgentManager:
stream_text_msg_id = None
stream_tool_msg_ids_ordered = []
stream_block_index_map = {}
+ # Mirror of the streamed assistant text. The SDK envelope that
+ # normally commits a reply never lands when a turn is stopped
+ # mid-stream, so without this the text the user just watched
+ # appear would evaporate. Cleared the instant a block commits.
+ _stream_text_accum = ""
# Per-turn aggregate trackers for the consolidated thinking
# message. We accumulate across every AssistantMessage in the
# turn (think → tool → think → tool → answer) and stream
@@ -2381,10 +2623,13 @@ class AgentManager:
# baseline to get THIS TURN'S delta. Without subtracting,
# the second turn's pill would show turn-1 work added
# to turn-2 work, the third would show all three, etc.
+ # Pill uses the FRESH lane (uncached input only). session.tokens
+ # ["input"] stays full for the context-fullness bar + cost; the
+ # bubble shows the NEW tokens this turn, not the cached re-reads.
_cum_in = 0
_cum_out = 0
if isinstance(session.tokens, dict):
- _cum_in = int(session.tokens.get("input", 0) or 0)
+ _cum_in = int(session.tokens.get("input_fresh", 0) or 0)
_cum_out = int(session.tokens.get("output", 0) or 0)
_cum_children_in = 0
_cum_children_out = 0
@@ -2395,7 +2640,7 @@ class AgentManager:
_ct = getattr(_child, "tokens", None)
if not isinstance(_ct, dict):
continue
- _cum_children_in += int(_ct.get("input", 0) or 0)
+ _cum_children_in += int(_ct.get("input_fresh", 0) or 0)
_cum_children_out += int(_ct.get("output", 0) or 0)
except Exception:
pass
@@ -2413,18 +2658,14 @@ class AgentManager:
_children_in = _cum_children_in
_children_out = _cum_children_out
+ # Fresh input + output = the NEW tokens this turn. The old
+ # framework-overhead subtraction is gone on purpose: it was an
+ # estimate to strip the cached static prefix out of the full
+ # input number, and the fresh lane already excludes that prefix
+ # exactly, so subtracting it again would double-discount to ~0.
_turn_total_tokens: int | None = (
_parent_in + _parent_out + _children_in + _children_out
)
- # Strip framework overhead so bubble shows what the user
- # actually controls. Floor at output so over-estimates can't
- # render absurdly small.
- if _turn_total_tokens and session.framework_overhead_tokens > 0:
- _adjusted = _turn_total_tokens - session.framework_overhead_tokens
- _floor = _parent_out + _children_out
- if _adjusted < _floor:
- _adjusted = _floor
- _turn_total_tokens = _adjusted
if not _turn_total_tokens or _turn_total_tokens <= 0:
_turn_total_tokens = None
consolidated = Message(
@@ -2469,6 +2710,7 @@ class AgentManager:
async def _run_streaming_turn():
nonlocal stream_text_msg_id, stream_tool_msg_ids_ordered, stream_block_index_map
+ nonlocal _stream_text_accum
nonlocal _turn_number, _first_event, _current_turn_emitted
# Per-turn thinking aggregation trackers (added for the
# "Thought for Ns · M tokens" persisted label). Without
@@ -2500,8 +2742,10 @@ class AgentManager:
# Snapshot cumulative tokens at turn start;
# subtracted at emit time for per-turn deltas.
try:
+ # Baselines track the SAME fresh lane the pill reads,
+ # so the per-turn delta is fresh-minus-fresh.
if isinstance(session.tokens, dict):
- _turn_baseline_session_in = int(session.tokens.get("input", 0) or 0)
+ _turn_baseline_session_in = int(session.tokens.get("input_fresh", 0) or 0)
_turn_baseline_session_out = int(session.tokens.get("output", 0) or 0)
_ch_in = 0
_ch_out = 0
@@ -2511,7 +2755,7 @@ class AgentManager:
_ct = getattr(_child, "tokens", None)
if not isinstance(_ct, dict):
continue
- _ch_in += int(_ct.get("input", 0) or 0)
+ _ch_in += int(_ct.get("input_fresh", 0) or 0)
_ch_out += int(_ct.get("output", 0) or 0)
_turn_baseline_children_in = _ch_in
_turn_baseline_children_out = _ch_out
@@ -2635,6 +2879,12 @@ class AgentManager:
if msg_id and delta_type == "text_delta":
_text_chunk = delta.get("text", "")
_turn_assistant_text_chars += len(_text_chunk)
+ _stream_text_accum += _text_chunk
+ self._live_partial[session_id] = {
+ "msg_id": stream_text_msg_id,
+ "text": _stream_text_accum,
+ "branch_id": session.active_branch_id,
+ }
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
"message_id": msg_id,
@@ -2840,7 +3090,9 @@ class AgentManager:
content=_asst_text,
branch_id=session.active_branch_id,
)
- session.messages.append(asst_msg)
+ self._upsert_message(session, asst_msg)
+ _stream_text_accum = ""
+ self._live_partial.pop(session_id, None)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": asst_msg.model_dump(mode="json"),
@@ -2849,7 +3101,7 @@ class AgentManager:
for i, tu in enumerate(tool_uses):
msg_id = stream_tool_msg_ids_ordered[i] if i < len(stream_tool_msg_ids_ordered) else uuid4().hex
tool_msg = Message(id=msg_id, role="tool_call", content=tu, branch_id=session.active_branch_id)
- session.messages.append(tool_msg)
+ self._upsert_message(session, tool_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": tool_msg.model_dump(mode="json"),
@@ -2903,6 +3155,9 @@ class AgentManager:
_pre_out = int(_pre_usage.get("output_tokens", 0) or 0)
if _pre_total_in > 0:
session.tokens["input"] = _pre_total_in
+ # Pill reads the fresh lane: uncached input only,
+ # so re-read/cached context doesn't inflate it.
+ session.tokens["input_fresh"] = _pre_in
if _pre_out > 0:
session.tokens["output"] = _pre_out
except Exception:
@@ -2970,6 +3225,7 @@ class AgentManager:
cache_read = usage.get("cache_read_input_tokens", 0) or 0
total_input = inp + cache_create + cache_read
session.tokens["input"] = total_input
+ session.tokens["input_fresh"] = inp
session.tokens["output"] = out
cost = getattr(message, "total_cost_usd", None)
@@ -3113,6 +3369,8 @@ class AgentManager:
"message_id": stream_text_msg_id,
})
stream_text_msg_id = None
+ _stream_text_accum = ""
+ self._live_partial.pop(session_id, None)
for _tool_msg_id in stream_tool_msg_ids_ordered:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
@@ -3155,7 +3413,24 @@ class AgentManager:
except Exception:
logger.exception("auto-continuation dispatch failed")
except asyncio.CancelledError:
- session.status = "stopped"
+ # Only act if we're still the session's live task. A user stop pops
+ # this task (stop_agent already finalized status + partial), and a
+ # follow-up message may have started a newer turn; either way this
+ # dying task must NOT clobber the live status or pop the new turn's
+ # in-flight partial mirror.
+ if self.tasks.get(session_id) is asyncio.current_task():
+ session.status = "stopped"
+ # A cancelled turn desyncs the CLI's resume transcript from
+ # session.messages (the SDK never recorded the interrupted
+ # turn), so force the next turn to rebuild history from
+ # session.messages, else resume/follow-ups replay a transcript
+ # with no trace of the stopped reply ("nothing to continue").
+ session.needs_fresh_session = True
+ # Persist whatever streamed before the cancel (edit / branch
+ # switch paths; the user-stop path already did this in stop_agent).
+ await self._commit_partial_now(session)
+ stream_text_msg_id = None
+ _stream_text_accum = ""
except Exception as e:
logger.exception(f"Agent {session_id} error: {e}")
session.status = "error"
@@ -3232,10 +3507,29 @@ 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)
+ elif p_is_transient_capacity_error(e, extra_text=_stderr_tail):
+ # A genuine throttle (429/overload/capacity) that already burned
+ # the whole silent-backoff budget (the only way one reaches here).
+ # It's a limit, not a failure, so don't append a system-message
+ # card; emit a transient signal for the muted pill and mark the
+ # turn completed so it doesn't read as an error.
+ session.status = "completed"
+ if stream_text_msg_id:
+ try:
+ await ws_manager.send_to_session(session_id, "agent:stream_end", {
+ "session_id": session_id,
+ "message_id": stream_text_msg_id,
+ })
+ except Exception:
+ pass
+ await ws_manager.send_to_session(session_id, "agent:rate_limited", {
+ "session_id": session_id,
+ "retry_after_s": parse_retry_after(e, _stderr_tail),
+ })
elif p_is_free_trial_exhausted(e, extra_text=_stderr_tail):
# Free runs spent. Flip back to own_key and show a friendly
# "connect a model" upsell instead of a raw 402.
@@ -3362,7 +3656,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)
@@ -3407,7 +3702,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)
@@ -3430,7 +3726,15 @@ class AgentManager:
"message": error_msg.model_dump(mode="json"),
})
finally:
- if session_id in self.sessions:
+ # Only the session's live task finalizes. A stopped task (popped by
+ # stop_agent, which already finalized status + saved) or one
+ # superseded by a newer turn must not pop the new turn's partial
+ # mirror, broadcast a stale terminal status, or overwrite the
+ # snapshot the live turn is writing.
+ _is_live_task = self.tasks.get(session_id) is asyncio.current_task()
+ if _is_live_task:
+ self._live_partial.pop(session_id, None)
+ if session_id in self.sessions and _is_live_task:
# For canvas-launched App Builder sessions, the workspace
# folder IS the session_id (see launch_agent), so meta.json
# lives at outputs_workspace//meta.json. Read it
@@ -3616,6 +3920,7 @@ class AgentManager:
hidden: bool = False,
selected_browser_ids: list[str] | None = None,
selected_app_output_ids: list[str] | None = None,
+ selected_setting_ids: list[str] | None = None,
client_message_id: str | None = None,
prepend_context: str | None = None,
):
@@ -3752,7 +4057,7 @@ class AgentManager:
if fast_verdict != "no":
task = asyncio.create_task(self._run_browser_fast_path(session_id, model_prompt, selected_browser_ids, fast_brief, fast_verdict))
else:
- task = asyncio.create_task(self._run_agent_loop(session_id, model_prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids))
+ task = asyncio.create_task(self._run_agent_loop(session_id, model_prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids, selected_setting_ids=selected_setting_ids))
self.tasks[session_id] = task
async def _run_browser_fast_path(self, session_id: str, prompt: str, selected_browser_ids: list[str] | None, brief: str = "", verdict: str = "act"):
@@ -3909,21 +4214,88 @@ class AgentManager:
session.pending_approvals = []
session.status = "stopped"
+ session.needs_fresh_session = True
if not session.closed_at:
session.closed_at = datetime.now()
+ # Persist the partial reply NOW, before tearing down the SDK. The
+ # cancel handler also does this, but it sits behind the generator's
+ # teardown, which can take several seconds; doing it here means the
+ # streamed text stays put the instant Stop is pressed instead of
+ # blinking out and reappearing once teardown finishes.
+ await self._commit_partial_now(session)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "stopped",
"session": session.model_dump(mode="json"),
})
+ # Snapshot now: the cancelled task's finally skips the save (it's no
+ # longer the live task once we pop it below), so persist the partial
+ # here or it'd live only in memory until the next turn / shutdown.
+ try:
+ _save_session(session_id, session.model_dump(mode="json"))
+ except Exception:
+ pass
- task = self.tasks.get(session_id)
+ # Drop the task from the registry immediately so a follow-up message
+ # isn't rejected as "still running" while the cancelled task slowly
+ # tears down (that window was eating user messages). Drain it in the
+ # background; we've already captured the partial above.
+ task = self.tasks.pop(session_id, None)
if task and not task.done():
task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
+ asyncio.create_task(self._drain_task(task))
+
+ async def _commit_partial_now(self, session) -> bool:
+ """Persist the in-flight streamed assistant text as a real message and
+ push it to the client, idempotently. Lets a stop show the partial
+ instantly instead of waiting out the SDK teardown the cancel handler
+ sits behind. Returns True if it committed something."""
+ live = self._live_partial.pop(session.id, None)
+ if not live:
+ return False
+ text = live.get("text") or ""
+ msg_id = live.get("msg_id")
+ if not msg_id or not text.strip():
+ return False
+ if any(getattr(m, "id", None) == msg_id for m in session.messages):
+ return False
+ partial = Message(
+ id=msg_id,
+ role="assistant",
+ content=text,
+ branch_id=live.get("branch_id") or session.active_branch_id,
+ )
+ self._upsert_message(session, partial)
+ try:
+ await ws_manager.send_to_session(session.id, "agent:message", {
+ "session_id": session.id,
+ "message": partial.model_dump(mode="json"),
+ })
+ await ws_manager.send_to_session(session.id, "agent:stream_end", {
+ "session_id": session.id,
+ "message_id": msg_id,
+ })
+ except Exception:
+ pass
+ return True
+
+ async def _drain_task(self, task) -> None:
+ """Await a cancelled task's (possibly slow) teardown off the hot path."""
+ try:
+ await task
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ def _upsert_message(self, session, msg) -> None:
+ """Append msg, or replace it in place if its id is already present.
+ Makes a duplicate-id row unrepresentable when a stream commit races a
+ stop's early partial commit (both carry the same stream message id).
+ Same pattern the consolidated-thinking pill already uses inline."""
+ for i, existing in enumerate(session.messages):
+ if getattr(existing, "id", None) == msg.id:
+ session.messages[i] = msg
+ return
+ session.messages.append(msg)
def handle_approval(self, request_id: str, decision: dict):
"""Resolve a pending HITL approval."""
@@ -4412,9 +4784,19 @@ class AgentManager:
"dashboard_id": session.dashboard_id,
})
+ self._purge_session_memory(session_id)
+ logger.info(f"Session {session_id} closed and persisted")
+
+ def _purge_session_memory(self, session_id: str) -> None:
+ """Drop a session from EVERY in-memory structure keyed by its id, so a
+ close or delete can't strand stale per-session state that lives until
+ the process dies. One chokepoint on purpose: a new per-session cache
+ wires its eviction in HERE and both removal paths get it for free."""
self.sessions.pop(session_id, None)
self.tasks.pop(session_id, None)
- logger.info(f"Session {session_id} closed and persisted")
+ self._live_partial.pop(session_id, None)
+ p_view_builder_render_retry_counts.pop(session_id, None)
+ p_view_builder_dirty_sessions.discard(session_id)
async def delete_session(self, session_id: str) -> None:
"""Permanently delete a session: remove from memory and JSON file.
@@ -4434,8 +4816,7 @@ class AgentManager:
except asyncio.CancelledError:
pass
- self.sessions.pop(session_id, None)
- self.tasks.pop(session_id, None)
+ self._purge_session_memory(session_id)
_delete_session_file(session_id)
logger.info(f"Session {session_id} permanently deleted")
@@ -4774,9 +5155,44 @@ 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 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 sid not in card_ids:
+ continue
+ if 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 _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/apps/agents/agents.py b/backend/apps/agents/agents.py
index 1933f463..44802b18 100644
--- a/backend/apps/agents/agents.py
+++ b/backend/apps/agents/agents.py
@@ -112,6 +112,7 @@ async def send_message(session_id: str, body: dict):
hidden=body.get("hidden", False),
selected_browser_ids=body.get("selected_browser_ids"),
selected_app_output_ids=body.get("selected_app_output_ids"),
+ selected_setting_ids=body.get("selected_setting_ids"),
client_message_id=body.get("client_message_id"),
)
return {"ok": True}
@@ -128,6 +129,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/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py
index 14ab253d..779ea6e0 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
@@ -1013,6 +1014,9 @@ async def run_browser_agent(
_in = response.usage.input_tokens or 0
out_tokens_total += _out
session.tokens["input"] = session.tokens.get("input", 0) + _in
+ # Already-uncached here (cache tracked separately below), so the
+ # fresh lane that feeds the parent's pill mirrors it 1:1.
+ session.tokens["input_fresh"] = session.tokens.get("input_fresh", 0) + _in
session.tokens["output"] = session.tokens.get("output", 0) + _out
_cr = getattr(response.usage, "cache_read_input_tokens", 0) or 0
_cw = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
@@ -1427,6 +1431,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 +2127,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/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py
index 95a8ad21..aef0b4dc 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.
@@ -12,10 +37,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
@@ -87,6 +132,17 @@ def p_is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
))
+def p_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 p_extract_reset_hint(text: str) -> str:
"""Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of
a provider usage error so we can tell the user when their limit comes back.
@@ -109,6 +165,10 @@ def p_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 p_is_translation_error(exc, extra_text):
+ return False
return bool(re.search(
r"\b(401|403)\b"
r"|invalid\s+authentication\s+credentials"
@@ -142,6 +202,24 @@ def p_is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
))
+def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None:
+ """Best-effort seconds-until-retry pulled from a throttle error; None if the
+ upstream didn't say. Only used to label the rate-limit pill, so a miss just
+ means the pill shows no countdown, never anything load-bearing."""
+ combined = f"{exc!s}\n{extra_text}"
+ # "1m 59s" / "2m" / "45s" (reset-window phrasing Codex/Anthropic use).
+ m = re.search(r"\b(?:(\d{1,2})\s*m(?:in)?)?\s*(\d{1,3})\s*s(?:ec)?\b", combined, re.IGNORECASE)
+ if m and (m.group(1) or m.group(2)):
+ return int(m.group(1) or 0) * 60 + int(m.group(2) or 0)
+ # "retry-after: 30" / "try again in 2 minutes".
+ m = re.search(r"(?:retry[-\s]?after|try\s+again\s+in)\D{0,8}(\d{1,4})\s*(m|min|minute|s|sec|second)?", combined, re.IGNORECASE)
+ if m:
+ n = int(m.group(1))
+ unit = (m.group(2) or "s").lower()
+ return n * 60 if unit.startswith("m") else n
+ return None
+
+
def p_is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
# The Claude CLI's underlying ProcessError stringifies to a generic
# "Command failed with exit code 1 / Check stderr output for details";
diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py
index 0471734b..50a57779 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__)
@@ -55,6 +56,11 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
"title": "Airtable",
"description": "Read and write records, manage bases, tables, and fields in the user's Airtable.",
},
+ {
+ "id": "GitHub",
+ "title": "GitHub",
+ "description": "Repos, issues, pull requests, Actions, code search, gists; when the task involves the user's GitHub.",
+ },
{
"id": "Reddit",
"title": "Reddit",
@@ -85,8 +91,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():
@@ -112,7 +120,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:
@@ -138,6 +146,26 @@ 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
+ # 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": ""}
+
+
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/apps/agents/core/models.py b/backend/apps/agents/core/models.py
index 00313ee0..7151c147 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)
diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py
index b6935496..484cca6d 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.
@@ -233,6 +262,28 @@ def _build_selected_app_context(selected_app_output_ids: list[str] | None) -> st
)
+def _build_selected_settings_context(selected_setting_ids: list[str] | None) -> str | None:
+ """Context block when the user points the agent at specific Settings rows.
+
+ A targeting aid, NOT a gate: the settings tools (SettingsRead/SettingsWrite)
+ are always available regardless. This just focuses the agent on the exact
+ fields the user clicked. Ids are AppSettings field names (e.g. 'theme',
+ 'default_model'), so no label map to drift out of date."""
+ ids = [s for s in (selected_setting_ids or []) if s]
+ if not ids:
+ return None
+ bullets = "\n".join(f"- {fid}" for fid in ids)
+ return (
+ "\n"
+ "The user pointed you at these specific OpenSwarm Settings fields. Focus "
+ "on them: call SettingsRead to see their current values, then "
+ "SettingsWrite to change what the user asked for. Leave unrelated "
+ "settings alone.\n"
+ f"{bullets}\n"
+ ""
+ )
+
+
def _build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str], get_all_tool_names: Callable[[], list[str]]) -> str | None:
"""Compact registry of installed MCP servers, one line per server.
@@ -304,6 +355,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."
@@ -402,13 +459,39 @@ def _resolve_forced_tools(forced_tools: list[str] | None) -> str:
def _resolve_attached_skills(attached_skills: list | None) -> str:
- """Build a context block injecting attached skill content into the prompt."""
+ """Build a context block injecting attached skill content into the prompt.
+
+ For a multi-file (folder) skill we inject the SKILL.md body as text AND point
+ the agent at the folder so it can read supporting files (scripts, templates)
+ on demand with the normal Read/Glob/Bash tools. That keeps skills fully
+ provider-agnostic: plain prompt text plus universal file tools, identical on
+ Claude, OpenAI, Gemini, or any custom model routed through 9router. The
+ folder lookup is resolved backend-side from the skill id so the frontend
+ send payload stays a simple {id, name, content}."""
if not attached_skills:
return ""
+ folder_by_id: dict[str, str] = {}
+ try:
+ from backend.apps.skills.skills import _sync_skills
+ for s in _sync_skills():
+ if s.dir_path and s.has_supporting_files:
+ folder_by_id[s.id] = s.dir_path
+ except Exception:
+ folder_by_id = {}
+
sections = []
for skill in attached_skills:
name = skill.get("name", "Unknown")
content = skill.get("content", "")
- if content:
- sections.append(f"[Using skill: {name}]\n\n{content}")
+ if not content:
+ continue
+ block = f"[Using skill: {name}]\n\n{content}"
+ folder = folder_by_id.get(skill.get("id", ""))
+ if folder:
+ block += (
+ f"\n\nThis skill bundles supporting files in {folder}. "
+ "Read them with your normal file tools (Read / Glob / Bash) when "
+ "the steps above call for one; don't guess their contents."
+ )
+ sections.append(block)
return "\n\n".join(sections)
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
diff --git a/backend/apps/agents/session_credential.py b/backend/apps/agents/session_credential.py
new file mode 100644
index 00000000..1facfad3
--- /dev/null
+++ b/backend/apps/agents/session_credential.py
@@ -0,0 +1,198 @@
+"""Which credential keeps a live agent session alive, as one typed value.
+
+The settings-meta tool lets an agent edit its own Settings autonomously. The
+single hard rule is "no suicide": it must never disconnect the credential that
+powers its own run. We enforce that structurally, not with a scattered if-check,
+by resolving the powering credential to a small closed value HERE, in one place,
+and having the write guard key off it.
+
+Add a provider lane and you add a case here; the exhaustive enumeration in
+test_settings_meta_guard.py walks every (provider x route x connection_mode)
+combo and fails until the new lane is classified, so a wrong/forgotten state
+can't ship silently.
+
+Honest scope: only API keys live in writable settings fields, so they're the
+only credential the guard can be asked to protect. Subscriptions (OpenSwarm
+Pro/free-trial, and the 9router OAuth lanes for Claude/Codex/Gemini) are either
+server-owned or live entirely outside settings.json, so the settings-meta tool
+cannot touch them at all, a stronger protection than the guard itself.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Literal, TYPE_CHECKING
+
+from backend.apps.agents.providers.registry import (
+ _CUSTOM_VALUE_PREFIX,
+ _custom_provider_slug_for_lookup,
+ _find_builtin_model,
+ _find_custom_provider_for_value,
+ get_api_type,
+)
+
+if TYPE_CHECKING:
+ from backend.apps.settings.models import AppSettings
+
+# AppSettings fields holding a user-writable API key, keyed by provider api-type.
+# Blanking whichever of these powers the current run is the one suicide the guard
+# stops. Anything not here (subscription tokens, bearers) is not settings-writable.
+_API_KEY_FIELD_BY_API: dict[str, str] = {
+ "anthropic": "anthropic_api_key",
+ "openai": "openai_api_key",
+ "codex": "openai_api_key",
+ "gemini": "google_api_key",
+ "openrouter": "openrouter_api_key",
+}
+
+# Every settings field that can hold an API key (the full guarded set). Custom
+# providers keep their keys inside the custom_providers list, guarded separately.
+ALL_API_KEY_FIELDS: frozenset[str] = frozenset(_API_KEY_FIELD_BY_API.values())
+
+CredentialKind = Literal["api_key", "subscription", "unknown"]
+
+
+@dataclass(frozen=True)
+class PoweringCredential:
+ """The credential keeping THIS run alive, resolved to a closed value.
+
+ kind=="api_key" -> protected_field (or custom slug) names exactly what
+ the guard must keep alive.
+ kind=="subscription" -> the live credential isn't a settings field at all
+ (Pro/free-trial/9router OAuth), so no api-key field
+ needs guarding; clearing OTHER keys stays allowed.
+ kind=="unknown" -> we couldn't classify the run; fail safe by treating
+ ALL credential fields as protected.
+ """
+
+ kind: CredentialKind
+ provider: str
+ protected_field: str | None = None
+ protected_custom_slug: str | None = None
+ label: str = ""
+
+
+def _custom_slug_for_model(model_value: str, settings: AppSettings) -> str | None:
+ cp = _find_custom_provider_for_value(settings, model_value)
+ if cp is not None:
+ return _custom_provider_slug_for_lookup(getattr(cp, "name", ""))
+ # Fall back to the slug encoded in the picker value itself.
+ if isinstance(model_value, str) and model_value.startswith(_CUSTOM_VALUE_PREFIX):
+ slug = model_value[len(_CUSTOM_VALUE_PREFIX):].partition("/")[0]
+ return slug or None
+ return None
+
+
+def resolve_powering_credential(model_value: str, settings: AppSettings) -> PoweringCredential:
+ """Resolve the credential powering a run on `model_value` to a typed value.
+
+ `model_value` is the session's short model name (e.g. "opus-4-8", "sonnet-api",
+ "custom/lmstudio/llama"), exactly what AgentSession.model holds.
+ """
+ entry = _find_builtin_model(model_value)
+ api = (entry or {}).get("api") or get_api_type(model_value)
+ route = (entry or {}).get("route")
+ mode = getattr(settings, "connection_mode", "own_key")
+
+ # Custom provider (LM Studio, Ollama, Together, ...). Local servers use a
+ # placeholder key, so suicide is removing the provider ENTRY, not blanking
+ # its key; the guard keys off the slug.
+ if api == "custom":
+ slug = _custom_slug_for_model(model_value, settings)
+ return PoweringCredential(
+ kind="api_key", provider="custom",
+ protected_custom_slug=slug,
+ label=f"custom provider '{slug}'" if slug else "custom provider",
+ )
+
+ # Explicit API-key route: the matching *_api_key field is the live one.
+ if route == "api":
+ field = _API_KEY_FIELD_BY_API.get(api)
+ if field:
+ return PoweringCredential(kind="api_key", provider=api, protected_field=field,
+ label=f"{field} (powers this run)")
+ return PoweringCredential(kind="unknown", provider=api,
+ label=f"{api} api route (unclassified)")
+
+ # Subscription-only routes (cx/ Codex, gc/ Gemini CLI) and pinned cc/ Claude:
+ # these lanes live in 9router, never in settings.
+ if route == "cc" or (entry or {}).get("subscription_only"):
+ return PoweringCredential(kind="subscription", provider=api,
+ label=f"{api} subscription")
+
+ # OpenRouter (its own `openrouter` route, plus xai/meta/deepseek/etc routed
+ # through it): always an API key, never a subscription.
+ if api == "openrouter":
+ return PoweringCredential(kind="api_key", provider="openrouter",
+ protected_field="openrouter_api_key",
+ label="OpenRouter API key (powers this run)")
+
+ # Default Anthropic rows (route is None): connection_mode picks the lane.
+ if api == "anthropic":
+ if mode in ("openswarm-pro", "free-trial"):
+ label = "OpenSwarm Pro" if mode == "openswarm-pro" else "OpenSwarm free trial"
+ return PoweringCredential(kind="subscription", provider="anthropic", label=label)
+ if getattr(settings, "anthropic_api_key", None):
+ return PoweringCredential(kind="api_key", provider="anthropic",
+ protected_field="anthropic_api_key",
+ label="Anthropic API key (powers this run)")
+ # No key, no proxy mode -> the user's Claude subscription via 9router.
+ return PoweringCredential(kind="subscription", provider="anthropic",
+ label="Claude subscription")
+
+ # Default Gemini rows (api gemini-cli, route None): the AG/gc OAuth lane is a
+ # subscription. A bare AI Studio key only powers the explicit -api rows above.
+ if api in ("gemini", "gemini-cli"):
+ return PoweringCredential(kind="subscription", provider="gemini",
+ label="Gemini subscription")
+
+ # Anything we can't place: protect everything (fail safe), never fail open.
+ return PoweringCredential(kind="unknown", provider=api or "unknown",
+ label=f"{api or 'unknown'} provider (unclassified)")
+
+
+def _is_blank(value: Any) -> bool:
+ """A credential write that removes the credential: None, "", or whitespace."""
+ if value is None:
+ return True
+ if isinstance(value, str):
+ return value.strip() == ""
+ return False
+
+
+def _powering_custom_slug_present(new_providers: Any, slug: str) -> bool:
+ """True if the powering custom provider's entry still exists after the write."""
+ if not isinstance(new_providers, list):
+ return False
+ for cp in new_providers:
+ name = cp.get("name") if isinstance(cp, dict) else getattr(cp, "name", None)
+ if name and _custom_provider_slug_for_lookup(name) == slug:
+ return True
+ return False
+
+
+def write_would_suicide(field: str, new_value: Any, powering: PoweringCredential) -> bool:
+ """True if writing `new_value` to `field` would disconnect the live credential.
+
+ Pure and total: every (field, value, powering) maps to a definite yes/no, so
+ the guard can't be tricked by an unhandled path. Only blanking/removing a
+ credential counts; SETTING a fresh key is a (re)connect, never suicide.
+ """
+ if field == "custom_providers":
+ # Removing the entry that powers a custom-provider run is suicide; a
+ # local provider's placeholder key being blanked is not. When the run is
+ # unknown, any custom run could be the live one, so refuse a vanish.
+ if powering.kind == "api_key" and powering.provider == "custom" and powering.protected_custom_slug:
+ return not _powering_custom_slug_present(new_value, powering.protected_custom_slug)
+ if powering.kind == "unknown":
+ return not _powering_custom_slug_present(new_value, powering.protected_custom_slug or "")
+ return False
+
+ if field in ALL_API_KEY_FIELDS:
+ if not _is_blank(new_value):
+ return False
+ if powering.kind == "unknown":
+ return True
+ return powering.kind == "api_key" and field == powering.protected_field
+
+ return False
diff --git a/backend/apps/agents/settings_meta_server.py b/backend/apps/agents/settings_meta_server.py
new file mode 100644
index 00000000..63ee1816
--- /dev/null
+++ b/backend/apps/agents/settings_meta_server.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+"""Stdio MCP server letting an agent read and edit its own OpenSwarm Settings.
+
+Two tools, SettingsRead and SettingsWrite, backed by /api/settings-meta. Always
+on, no activation gate (Settings is the agent's own house). The backend enforces
+the only hard rule: it can change anything EXCEPT disconnect the credential
+powering its own run ("no suicide"), and reads come back with secrets redacted
+to configured/not, never the value. Both guards live server-side so this thin
+client can't weaken them."""
+
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
+BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
+BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/settings-meta"
+PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
+
+
+TOOLS = [
+ {
+ "name": "SettingsRead",
+ "description": (
+ "Read the user's OpenSwarm Settings (model defaults, theme, prompts, "
+ "connected providers, toggles). Secrets come back as configured/not, "
+ "never the actual key. Call this before SettingsWrite so you change "
+ "the right field to the right value."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": False,
+ },
+ },
+ {
+ "name": "SettingsWrite",
+ "description": (
+ "Change one or more OpenSwarm Settings. Pass `changes` as a map of "
+ "setting field name to new value (use the exact field names from "
+ "SettingsRead, e.g. {\"theme\": \"light\", \"default_model\": \"opus-4-8\"}). "
+ "You can set or clear API keys too. Two things you cannot do: clear the "
+ "credential currently powering YOU (it's refused so you don't cut your "
+ "own run off), and touch subscription/connection state (managed by the "
+ "Subscription section; tell the user to use it). The result reports each "
+ "field as applied / refused / unknown, so relay what actually changed."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "changes": {
+ "type": "object",
+ "description": "Field name -> new value. e.g. {\"theme\": \"light\"}.",
+ "additionalProperties": True,
+ },
+ },
+ "required": ["changes"],
+ "additionalProperties": False,
+ },
+ },
+]
+
+
+def send_response(id_, result=None, error=None):
+ msg = {"jsonrpc": "2.0", "id": id_}
+ if error is not None:
+ msg["error"] = error
+ else:
+ msg["result"] = result
+ sys.stdout.write(json.dumps(msg) + "\n")
+ sys.stdout.flush()
+
+
+def call_backend(action: str, payload: dict) -> dict:
+ full = {**payload, "parent_session_id": PARENT_SESSION_ID}
+ body = json.dumps(full).encode()
+ headers = {"Content-Type": "application/json"}
+ if BACKEND_AUTH:
+ headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
+ req = urllib.request.Request(
+ f"{BACKEND_URL}/{action}", data=body, headers=headers, method="POST"
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ return json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ detail = e.read().decode() if e.fp else str(e)
+ return {"error": f"HTTP {e.code}: {detail}"}
+ except Exception as e:
+ return {"error": str(e)}
+
+
+def _format_read(settings: dict) -> str:
+ """Render redacted settings compactly so the model spends tokens on the
+ values it can act on, not on JSON punctuation."""
+ lines = ["Current OpenSwarm Settings (secrets shown as configured/not):"]
+ for key in sorted(settings.keys()):
+ val = settings[key]
+ if isinstance(val, dict) and "configured" in val:
+ state = f"configured (…{val['last4']})" if val.get("configured") else "not configured"
+ lines.append(f"- {key}: {state}")
+ else:
+ lines.append(f"- {key}: {json.dumps(val)}")
+ return "\n".join(lines)
+
+
+def _format_write(outcomes: dict) -> str:
+ applied = [f for f, o in outcomes.items() if o.get("status") == "applied"]
+ parts = []
+ if applied:
+ parts.append("Applied: " + ", ".join(sorted(applied)))
+ for field, o in outcomes.items():
+ status = o.get("status")
+ if status in ("applied", None):
+ continue
+ # "error" is transient (retryable); "refused"/"unknown" are not.
+ verb = "Failed" if status == "error" else "Refused"
+ parts.append(f"{verb} {field}: {o.get('reason', status)}")
+ if not parts:
+ return "No changes were applied."
+ return "\n".join(parts)
+
+
+def handle_tool_call(tool_name: str, arguments: dict) -> dict:
+ if tool_name == "SettingsRead":
+ result = call_backend("read", {})
+ if "error" in result:
+ return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
+ return {"content": [{"type": "text", "text": _format_read(result.get("settings", {}))}]}
+
+ if tool_name == "SettingsWrite":
+ changes = arguments.get("changes")
+ if not isinstance(changes, dict) or not changes:
+ return {"content": [{"type": "text", "text": "Error: `changes` must be a non-empty object of field -> value."}], "isError": True}
+ result = call_backend("write", {"changes": changes})
+ if "error" in result:
+ return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
+ return {"content": [{"type": "text", "text": _format_write(result.get("outcomes", {}))}]}
+
+ return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
+
+
+def main():
+ for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ msg = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ method = msg.get("method")
+ id_ = msg.get("id")
+ params = msg.get("params", {})
+
+ if method == "initialize":
+ send_response(id_, {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {"tools": {}},
+ "serverInfo": {"name": "openswarm-settings-meta", "version": "1.0.0"},
+ })
+ elif method == "notifications/initialized":
+ pass
+ elif method == "tools/list":
+ send_response(id_, {"tools": TOOLS})
+ elif method == "tools/call":
+ tool_name = params.get("name", "")
+ arguments = params.get("arguments", {})
+ try:
+ send_response(id_, handle_tool_call(tool_name, arguments))
+ except Exception as e:
+ send_response(id_, error={"code": -32000, "message": str(e)})
+ elif method == "resources/list":
+ send_response(id_, {"resources": []})
+ elif method == "prompts/list":
+ send_response(id_, {"prompts": []})
+ elif id_ is not None:
+ send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
+
+
+if __name__ == "__main__":
+ main()
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/apps/dashboards/models.py b/backend/apps/dashboards/models.py
index 2b203965..f3729af5 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/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py
index eee3e4a1..6e63119a 100644
--- a/backend/apps/nine_router/process.py
+++ b/backend/apps/nine_router/process.py
@@ -16,7 +16,9 @@ import logging
import os
import secrets
import shutil
+import socket
import subprocess
+import tempfile
import time
from typing import Any
@@ -54,8 +56,41 @@ 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
+# 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
@@ -68,13 +103,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
@@ -297,7 +348,52 @@ 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. 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"
@@ -325,100 +421,105 @@ 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()
- 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, 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"):
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, 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"}
- # 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():
diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md
index ed740f0b..4fbc82e1 100644
--- a/backend/apps/outputs/app_builder_skill.md
+++ b/backend/apps/outputs/app_builder_skill.md
@@ -325,6 +325,45 @@ export const JOBS_LIST = '/api/jobs/list';
---
+## Publishable AI + compute — `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE`
+
+The FastAPI backend above runs in preview but is **not hosted when an app is
+published** to the web. For features that should keep working on a published
+`{slug}.openswarm.host` link, use these two runtime calls instead of a backend.
+They run on the published site (same-origin, no credentials). In the App Builder
+**preview** they throw a clear "available once published" error, preview can't run
+them without embedding a credential into your app, so test these by publishing.
+
+**AI (Claude):** call `window.OUTPUT_LLM` with an Anthropic-style messages body.
+The model is chosen for you (a cheap default), so don't pass one.
+
+```ts
+const res = await window.OUTPUT_LLM({
+ messages: [{ role: 'user', content: prompt }],
+ max_tokens: 512,
+});
+const data = await res.json();
+const text = data.content[0].text;
+```
+
+**Data-shaping compute:** put pure Python (json/math/csv/datetime only — no
+network, no files) in a top-level `backend.py` that reads `input_data` and assigns
+`result`, then call `window.OUTPUT_COMPUTE(input)`:
+
+```python
+# backend.py
+result = {"total": sum(input_data["nums"])}
+```
+```ts
+const out = await window.OUTPUT_COMPUTE({ nums: [1, 2, 3] }); // -> { total: 6 }
+```
+
+Rule of thumb: if the app should be publishable, reach for `OUTPUT_LLM` /
+`OUTPUT_COMPUTE` first; only use the FastAPI backend for preview-only tools or
+things those two can't do (it won't be there once published).
+
+---
+
## Debugging — use `swarm_debug`, not `print()`
The backend has `swarm_debug` pre-installed. It's a colored frame-aware
diff --git a/backend/apps/outputs/html_inject.py b/backend/apps/outputs/html_inject.py
index c9fd8d1b..ba56f156 100644
--- a/backend/apps/outputs/html_inject.py
+++ b/backend/apps/outputs/html_inject.py
@@ -54,20 +54,36 @@ def _validate_against_schema(data: dict, schema: dict) -> str | None:
return f"Schema validation failed at {path}: {exc.message}"
-def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null") -> str:
+def _runtime_helpers_js() -> str:
+ """OUTPUT_COMPUTE / OUTPUT_LLM only run for real on the published edge, where they
+ are same-origin and carry NO credentials. In the App Builder preview we
+ deliberately do NOT wire them to the authenticated backend: doing so would embed
+ this install's token into the app's own JS (the exact exposure SECURITY.md item A
+ is about). Preview defines readable stubs instead, the app degrades with a clear
+ message rather than crashing or leaking a credential."""
+ return (
+ " window.OUTPUT_COMPUTE = async function () { throw new Error('OUTPUT_COMPUTE runs once this app is published.'); };\n"
+ " window.OUTPUT_LLM = async function () { throw new Error('OUTPUT_LLM runs once this app is published.'); };\n"
+ )
+
+
+def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null", with_runtime: bool = False) -> str:
"""Build a """
+
+
+def inject_runtime(html: bytes) -> bytes:
+ """Insert the runtime shim into an HTML document (before , else before
+ , else prepend). Returns bytes so callers can serve it directly."""
+ try:
+ text = html.decode("utf-8")
+ except UnicodeDecodeError:
+ return html # not text we can safely rewrite; serve as-is
+ if "" in text:
+ out = text.replace("", _RUNTIME_SHIM + "\n", 1)
+ elif " str | None:
+ """Extract the app slug from a {slug}.openswarm.host Host header. Rejects the
+ apex, www, multi-label subdomains, and anything not slug-shaped."""
+ host = (host or "").split(":")[0].lower()
+ suffix = "." + APPS_BASE_DOMAIN
+ if not host.endswith(suffix):
+ return None
+ sub = host[: -len(suffix)]
+ if not sub or "." in sub or sub == "www":
+ return None
+ return sub if _SLUG_RE.match(sub) else None
+
+
+def client_ip(request: Request) -> str:
+ return request.headers.get("fly-client-ip") or (request.client.host if request.client else "unknown")
+
+
+def _security_headers() -> dict[str, str]:
+ # The hard isolation is the separate apex (no openswarm.com cookies are reachable
+ # here). CSP is defense-in-depth: block embedding + sniffing. We deliberately do
+ # NOT lock script/connect-src, arbitrary apps need to run their own JS and call
+ # their own APIs; the pre-publish scan is what screens for abusive content.
+ return {
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": "frame-ancestors 'none'",
+ "Referrer-Policy": "no-referrer-when-downgrade",
+ "Cache-Control": "public, max-age=60",
+ }
+
+
+@app.get("/__edge/health")
+async def health() -> JSONResponse:
+ return JSONResponse({"ok": True})
+
+
+@app.post("/__compute")
+async def edge_compute(request: Request) -> Response:
+ slug = slug_from_host(request.headers.get("host", ""))
+ if not slug:
+ return JSONResponse({"error": "unknown app"}, status_code=404)
+ if not _compute_limiter.allow(client_ip(request)):
+ return JSONResponse({"error": "Too many requests, slow down."}, status_code=429)
+ bundle = await get_bundle(slug)
+ if bundle is None:
+ return JSONResponse({"error": "app not found"}, status_code=404)
+ if not bundle.backend_code:
+ return JSONResponse({"error": "this app has no compute backend"}, status_code=404)
+ try:
+ payload = await request.json()
+ except Exception:
+ payload = {}
+ raw_input = payload.get("input_data", payload) if isinstance(payload, dict) else {}
+ input_data = raw_input if isinstance(raw_input, dict) else {}
+ try:
+ res = await run_backend(bundle.backend_code, input_data)
+ except UnsafeCodeError:
+ return JSONResponse({"error": "this app's backend can't run here"}, status_code=400)
+ except Exception:
+ return JSONResponse({"error": "compute failed"}, status_code=500)
+ return JSONResponse({"result": res.result, "stdout": res.stdout})
+
+
+@app.post("/__llm")
+async def edge_llm(request: Request) -> Response:
+ slug = slug_from_host(request.headers.get("host", ""))
+ if not slug:
+ return JSONResponse({"error": "unknown app"}, status_code=404)
+ if not _llm_limiter.allow(client_ip(request)):
+ return JSONResponse(
+ {"type": "error", "error": {"type": "rate_limited", "message": "Too many requests."}},
+ status_code=429,
+ )
+ body = await request.body()
+ headers = {
+ "x-edge-auth": EDGE_AUTH_TOKEN,
+ "x-app-slug": slug,
+ "content-type": "application/json",
+ }
+ for k in ("anthropic-version", "anthropic-beta"):
+ v = request.headers.get(k)
+ if v:
+ headers[k] = v
+
+ client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=None))
+ upstream_req = client.build_request("POST", f"{CLOUD_INTERNAL_URL}/api/apps/internal/llm", headers=headers, content=body)
+ try:
+ upstream = await client.send(upstream_req, stream=True)
+ except httpx.HTTPError:
+ await client.aclose()
+ return JSONResponse(
+ {"type": "error", "error": {"type": "upstream_unreachable", "message": "This app's AI is unavailable."}},
+ status_code=502,
+ )
+
+ async def relay():
+ try:
+ async for chunk in upstream.aiter_raw():
+ yield chunk
+ finally:
+ await upstream.aclose()
+ await client.aclose()
+
+ return StreamingResponse(
+ relay(),
+ status_code=upstream.status_code,
+ media_type=upstream.headers.get("content-type", "application/json"),
+ )
+
+
+@app.get("/{path:path}")
+async def serve_static(path: str, request: Request) -> Response:
+ slug = slug_from_host(request.headers.get("host", ""))
+ if not slug:
+ return HTMLResponse(apex_page(), status_code=404)
+ bundle = await get_bundle(slug)
+ if bundle is None:
+ return HTMLResponse(not_found_page(), status_code=404)
+ resolved = resolve_file(bundle, path)
+ if resolved is None:
+ return HTMLResponse(not_found_page(), status_code=404)
+ data, mime = resolved
+ if mime == "text/html":
+ # Give the page the published-app runtime (OUTPUT_COMPUTE / OUTPUT_LLM).
+ data = inject_runtime(data)
+ return Response(content=data, media_type=mime, headers=_security_headers())
diff --git a/openswarm-edge/app/ratelimit.py b/openswarm-edge/app/ratelimit.py
new file mode 100644
index 00000000..e990478f
--- /dev/null
+++ b/openswarm-edge/app/ratelimit.py
@@ -0,0 +1,44 @@
+"""Tiny in-memory per-key fixed-window rate limiter. Guards /__compute and /__llm
+so one visitor or scraper can't burn a creator's budget or our CPU. Best-effort
+and single-process: the cloud's budget ledger is the hard backstop, this just
+keeps the obvious abuse out cheaply."""
+from __future__ import annotations
+
+import time
+
+# Hard ceiling on tracked keys so a flood of unique IPs can't grow the map without
+# bound. Past it we evict the oldest-inserted keys in a batch, never the whole map:
+# a dropped key just gets a fresh allowance, so the worst case is being briefly
+# lenient to a few stale IPs, not wiping every live visitor's count at once.
+_MAX_KEYS = 50_000
+_EVICT_BATCH = _MAX_KEYS // 10
+
+
+class RateLimiter:
+ def __init__(self, limit: int, window_seconds: float):
+ self.limit = limit
+ self.window = window_seconds
+ self._hits: dict[str, list[float]] = {}
+
+ def allow(self, key: str) -> bool:
+ now = time.time()
+ if len(self._hits) > _MAX_KEYS:
+ for stale in list(self._hits)[:_EVICT_BATCH]:
+ self._hits.pop(stale, None)
+ bucket = self._hits.get(key)
+ if bucket is None:
+ bucket = []
+ self._hits[key] = bucket
+ cutoff = now - self.window
+ # Drop timestamps that fell out of the window.
+ keep = 0
+ for t in bucket:
+ if t >= cutoff:
+ break
+ keep += 1
+ if keep:
+ del bucket[:keep]
+ if len(bucket) >= self.limit:
+ return False
+ bucket.append(now)
+ return True
diff --git a/openswarm-edge/app/sandbox.py b/openswarm-edge/app/sandbox.py
new file mode 100644
index 00000000..c7a0947e
--- /dev/null
+++ b/openswarm-edge/app/sandbox.py
@@ -0,0 +1,123 @@
+"""Sandboxed Python runner for published apps' backend.py compute.
+
+VENDORED from backend/apps/outputs/executor.py (the desktop App Builder runtime).
+Keep the allow/deny lists + the subprocess hardening in sync with that file; this
+is the same data-shaping sandbox, just running in the edge instead of on the
+desktop. Pure compute only: no network, no disk, no subprocess, no secrets. Safe
+to run multi-tenant on one machine because nothing here can reach shared state."""
+from __future__ import annotations
+
+import ast
+import asyncio
+import json
+import os
+import sys
+import tempfile
+from dataclasses import dataclass
+
+TIMEOUT_SECONDS = 30
+
+_ALLOWED_MODULES = frozenset({
+ "json", "math", "re", "datetime", "collections", "itertools",
+ "functools", "statistics", "decimal", "fractions", "random",
+ "string", "textwrap", "unicodedata", "csv", "copy", "enum",
+ "dataclasses", "typing", "abc", "numbers", "uuid", "hashlib",
+ "base64", "binascii", "operator", "heapq", "bisect", "array",
+})
+
+_BLOCKED_BUILTINS = frozenset({
+ "exec", "eval", "compile", "__import__", "open", "input",
+ "breakpoint", "exit", "quit",
+})
+
+
+class UnsafeCodeError(Exception):
+ """AST validation rejected the backend code."""
+
+
+def validate_code_safety(code: str) -> None:
+ """Raise UnsafeCodeError on the first AST-visible risk. Published apps are
+ vetted at publish time, but we re-check here: the edge never trusts that the
+ bundle in storage matches what was scanned."""
+ try:
+ tree = ast.parse(code)
+ except SyntaxError as e:
+ raise UnsafeCodeError(f"Syntax error: {e}")
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ if alias.name.split(".")[0] not in _ALLOWED_MODULES:
+ raise UnsafeCodeError(f"import '{alias.name}' is not allowed")
+ elif isinstance(node, ast.ImportFrom):
+ if node.module and node.module.split(".")[0] not in _ALLOWED_MODULES:
+ raise UnsafeCodeError(f"import from '{node.module}' is not allowed")
+ elif isinstance(node, ast.Call):
+ if isinstance(node.func, ast.Name) and node.func.id in _BLOCKED_BUILTINS:
+ raise UnsafeCodeError(f"builtin '{node.func.id}()' is not allowed")
+
+
+def _minimal_env() -> dict:
+ return {
+ "PYTHONDONTWRITEBYTECODE": "1",
+ "LANG": os.environ.get("LANG", "C.UTF-8"),
+ "LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"),
+ "PYTHONUTF8": "1",
+ "PYTHONIOENCODING": "utf-8",
+ }
+
+
+@dataclass
+class ComputeResult:
+ result: dict
+ stdout: str
+
+
+async def run_backend(code: str, input_data: dict) -> ComputeResult:
+ """Validate + execute user backend code in a hardened subprocess. The code
+ reads `input_data` (a global dict) and assigns a global `result` dict."""
+ validate_code_safety(code)
+
+ preamble = (
+ "import json, sys, io, builtins\n"
+ "for _b in ('exec','eval','compile','open','input',\n"
+ " 'breakpoint','exit','quit'):\n"
+ " try: delattr(builtins, _b)\n"
+ " except AttributeError: pass\n"
+ "_orig_stdout = sys.stdout\n"
+ "_capture = io.StringIO()\n"
+ "sys.stdout = _capture\n"
+ "input_data = json.loads(sys.stdin.read())\n"
+ "result = {}\n"
+ )
+ postamble = (
+ "\nsys.stdout = _orig_stdout\n"
+ 'json.dump({"__stdout__": _capture.getvalue(), "__result__": result}, sys.stdout)\n'
+ )
+ wrapper = preamble + code + postamble
+
+ with tempfile.TemporaryDirectory(prefix="osw-edge-exec-") as workdir:
+ proc = await asyncio.create_subprocess_exec(
+ sys.executable, "-c", wrapper,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=workdir,
+ env=_minimal_env(),
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(
+ proc.communicate(input=json.dumps(input_data).encode()),
+ timeout=TIMEOUT_SECONDS,
+ )
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.wait()
+ raise RuntimeError(f"compute timed out after {TIMEOUT_SECONDS}s")
+
+ if proc.returncode != 0:
+ raise RuntimeError(f"compute failed: {stderr.decode(errors='replace').strip()[:500]}")
+ try:
+ parsed = json.loads(stdout.decode())
+ except json.JSONDecodeError:
+ raise RuntimeError("compute did not return valid JSON")
+ return ComputeResult(result=parsed.get("__result__", {}), stdout=parsed.get("__stdout__", ""))
diff --git a/openswarm-edge/fly.toml b/openswarm-edge/fly.toml
new file mode 100644
index 00000000..c90a3101
--- /dev/null
+++ b/openswarm-edge/fly.toml
@@ -0,0 +1,40 @@
+# openswarm-edge: serves *.openswarm.host. Public-facing, least-privileged. The
+# only secrets it gets (via `fly secrets`, never here) are a READ-ONLY Tigris key
+# and EDGE_AUTH_TOKEN. Its call to the cloud rides the private 6PN mesh, not the
+# public internet. Wildcard cert: `fly certs create "*.openswarm.host" -a openswarm-edge`.
+app = 'openswarm-edge'
+primary_region = 'iad'
+kill_signal = 'SIGINT'
+kill_timeout = '30s'
+
+[build]
+ dockerfile = 'Dockerfile'
+
+[env]
+ PORT = '8080'
+ APPS_BASE_DOMAIN = 'openswarm.host'
+ # Private 6PN address of the cloud app (same org). Not public.
+ OPENSWARM_CLOUD_INTERNAL_URL = 'http://openswarm-cloud.internal:8080'
+ TIGRIS_ENDPOINT = 'https://fly.storage.tigris.dev'
+ TIGRIS_BUCKET = 'openswarm-app-bundles'
+
+[http_service]
+ internal_port = 8080
+ force_https = true
+ auto_stop_machines = 'off'
+ min_machines_running = 1
+ [http_service.concurrency]
+ type = 'requests'
+ hard_limit = 250
+ soft_limit = 200
+ [[http_service.checks]]
+ interval = '30s'
+ timeout = '5s'
+ grace_period = '10s'
+ method = 'get'
+ path = '/__edge/health'
+
+[[vm]]
+ cpu_kind = 'shared'
+ cpus = 1
+ memory_mb = 512
diff --git a/openswarm-edge/requirements.txt b/openswarm-edge/requirements.txt
new file mode 100644
index 00000000..e550411a
--- /dev/null
+++ b/openswarm-edge/requirements.txt
@@ -0,0 +1,4 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+boto3==1.35.90
+httpx==0.28.1
diff --git a/openswarm-edge/tests/test_edge.py b/openswarm-edge/tests/test_edge.py
new file mode 100644
index 00000000..9d4870c0
--- /dev/null
+++ b/openswarm-edge/tests/test_edge.py
@@ -0,0 +1,102 @@
+"""Unit tests for the edge's pure logic: Host->slug parsing, path-safe file
+resolution, the rate limiter, and the vendored sandbox. The Tigris fetch + cloud
+proxy need live services and are exercised in the staging E2E, not here.
+
+Run with: .venv/bin/python -m pytest tests/test_edge.py
+"""
+import asyncio
+import io
+import os
+import sys
+import tarfile
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from app.main import slug_from_host
+from app.bundles import unpack, resolve_file
+from app.inject import inject_runtime
+from app.ratelimit import RateLimiter
+from app.sandbox import validate_code_safety, run_backend, UnsafeCodeError
+
+
+def test_slug_from_host():
+ assert slug_from_host("notes.openswarm.host") == "notes"
+ assert slug_from_host("notes.openswarm.host:443") == "notes"
+ assert slug_from_host("UPPER.openswarm.host") == "upper"
+ assert slug_from_host("openswarm.host") is None # apex
+ assert slug_from_host("www.openswarm.host") is None # www
+ assert slug_from_host("a.b.openswarm.host") is None # multi-label
+ assert slug_from_host("notes.evil.com") is None # wrong domain
+ assert slug_from_host("bad_slug.openswarm.host") is None # underscore
+
+
+def _mk_tar(files: dict[str, bytes]) -> bytes:
+ buf = io.BytesIO()
+ with tarfile.open(fileobj=buf, mode="w:gz") as t:
+ for name, data in files.items():
+ info = tarfile.TarInfo(name=name)
+ info.size = len(data)
+ t.addfile(info, io.BytesIO(data))
+ return buf.getvalue()
+
+
+def test_resolve_file_paths():
+ b = unpack(_mk_tar({
+ "index.html": b"home",
+ "assets/app.js": b"console.log(1)",
+ "backend.py": b"result={}",
+ }))
+ assert resolve_file(b, "/")[0] == b"home"
+ assert resolve_file(b, "assets/app.js")[1] == "text/javascript"
+ assert resolve_file(b, "deep/spa/route")[0] == b"home" # SPA fallback
+ assert resolve_file(b, "backend.py")[0] == b"home" # never serve source
+ assert resolve_file(b, "../../etc/passwd")[0] == b"home" # traversal blocked
+
+
+def test_backend_code_available_for_compute_not_static():
+ b = unpack(_mk_tar({"index.html": b"x", "backend.py": b"import math\nresult={}"}))
+ assert b.backend_code == "import math\nresult={}"
+ data, _ = resolve_file(b, "backend.py")
+ assert data == b"x"
+
+
+def test_rate_limiter():
+ rl = RateLimiter(limit=3, window_seconds=100)
+ assert all(rl.allow("ip1") for _ in range(3))
+ assert rl.allow("ip1") is False # 4th over the limit
+ assert rl.allow("ip2") is True # a different key is independent
+
+
+def test_sandbox_rejects_unsafe_and_allows_safe():
+ try:
+ validate_code_safety("import os\nresult={}")
+ assert False, "expected UnsafeCodeError"
+ except UnsafeCodeError:
+ pass
+ validate_code_safety("import math\nresult={'x': math.pi}") # no raise
+
+
+def test_sandbox_runs_safe_code():
+ res = asyncio.run(run_backend("result = {'sum': sum(input_data['nums'])}", {"nums": [1, 2, 3]}))
+ assert res.result == {"sum": 6}
+
+
+def test_inject_runtime():
+ out = inject_runtime(b"xhi").decode()
+ assert "OUTPUT_COMPUTE" in out and "OUTPUT_LLM" in out
+ assert out.index("OUTPUT_COMPUTE") < out.index("") # injected inside
+ # no head/body: shim is prepended, original content preserved
+ bare = inject_runtime(b"bare
").decode()
+ assert "OUTPUT_COMPUTE" in bare and bare.endswith("bare
")
+
+
+def _run_all():
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
+ for fn in fns:
+ fn()
+ print(f"ok {fn.__name__}")
+ print(f"\n{len(fns)} passed")
+
+
+if __name__ == "__main__":
+ _run_all()
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."
diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1
index 9413c2f6..2755e678 100644
--- a/scripts/build-app-win.ps1
+++ b/scripts/build-app-win.ps1
@@ -281,6 +281,10 @@ if (-not (Test-Path (Join-Path $ProjectRoot 'electron\python-env'))) {
}
Write-Host "Python environment ready."
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
@@ -363,6 +367,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-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 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)
+ $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 {
+ 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)" }
+ 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
+ $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] 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
# backend/data/ tree was dead weight (and was leaking the dev machine's
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))
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)."