[eric] scheduled tasks: agent can now manage workflows itself + tons of safety, cross-platform polish, and edge-case fixes

This commit is contained in:
ciregenz
2026-05-18 03:14:24 -07:00
parent eb561d7187
commit 05f6897b40
213 changed files with 3638 additions and 7047 deletions
+14 -71
View File
@@ -1,34 +1,6 @@
// Node-runtime patch loaded via `node --require <this>` before 9router boots.
//
// Why this exists
// ---------------
// OpenAI's GPT-5 family (gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, …)
// rejects the legacy `max_tokens` parameter with HTTP 400, requiring
// `max_completion_tokens`. 9router (every released version, including 0.4.20)
// blindly forwards `max_tokens` in its Anthropic→OpenAI translator. We can't
// fix 9router from outside (env vars are ignored, baseUrl on the openai
// provider is hardcoded, prefix routing falls back). Instead we intercept
// the HTTPS write at the Node syscall layer — the actual boundary OpenAI
// sees — and rename the field on the way out.
//
// Safety contract
// ---------------
// • Scope: only requests whose hostname is `api.openai.com`. Every other
// outbound HTTP/HTTPS call passes through unmodified.
// • Model gate: only requests whose body parses as JSON with
// `model.startsWith("gpt-5")` (after stripping common prefixes 9router
// adds). GPT-4 / Claude / etc. unaffected.
// • Failure mode: every step is wrapped in try/catch and falls back to the
// unmodified original on any error. Worst case is "request behaves
// exactly as it would without this patch" — never worse than baseline.
// • Idempotency: the patch self-flags so re-loading via multiple --require
// doesn't double-wrap.
//
// Verification
// ------------
// Set OPENSWARM_DEBUG_GPT5_PATCH=1 in the env to log "[openswarm] 9router-
// gpt5-patch installed" on stderr and "rewrote max_tokens → max_completion_tokens"
// on each rewrite.
// Rewrites `max_tokens` to `max_completion_tokens` for GPT-5 calls (which 9router still emits) and floors completion tokens at 32K for reasoning headroom.
// Hostname-gated to api.openai.com; every step is try/catch so failure falls back to baseline behavior.
'use strict';
@@ -40,7 +12,7 @@ const DEBUG = process.env.OPENSWARM_DEBUG_GPT5_PATCH === '1';
function _log(msg) {
if (DEBUG) {
try { process.stderr.write('[openswarm-gpt5-patch] ' + msg + '\n'); } catch (_) { /* ignore */ }
try { process.stderr.write('[openswarm-gpt5-patch] ' + msg + '\n'); } catch (_) {}
}
}
@@ -48,9 +20,7 @@ function isGpt5Model(model) {
if (typeof model !== 'string') return false;
let m = model.trim().toLowerCase();
if (!m) return false;
// Strip routing prefixes 9router may have added: cp-openai/, openai/,
// cx/, openrouter/, or:openai/. Don't strip cp- (custom-provider) blindly
// because cp-anything could match a non-OpenAI custom node.
// Strip 9router prefixes; don't blindly strip cp- (could be a non-OpenAI custom node).
const prefixes = ['cp-openai/', 'openai/', 'cx/', 'openrouter/', 'or:openai/'];
for (const p of prefixes) {
if (m.startsWith(p)) { m = m.slice(p.length); break; }
@@ -58,19 +28,7 @@ function isGpt5Model(model) {
return m.startsWith('gpt-5');
}
// Minimum completion-token budget for GPT-5 reasoning models.
// GPT-5 burns 8-30K tokens on internal reasoning BEFORE producing any
// user-visible output. The Anthropic CLI's default max_tokens (~4096) is
// way under that floor — OpenAI accepts the request, runs reasoning until
// it hits the cap, then returns "Could not finish the message because
// max_tokens or model output limit was reached" with zero user-visible
// content. Floor at 32K so reasoning has room AND the user gets an
// actual response. Cost is unaffected because OpenAI bills for
// tokens-consumed, not max_completion_tokens (which is just a cap).
//
// We use max(requestedValue, 32K) — never lower the user's value, only
// raise it. If the user explicitly sets a high value (e.g. 100K) we
// honor it untouched.
// GPT-5 burns 8-30K reasoning tokens before any output; the CLI's default 4096 caps before content lands. Floor at 32K and only raise, never lower.
const GPT5_MIN_COMPLETION_TOKENS = 32768;
function maybeRewriteBody(bodyStr) {
@@ -80,8 +38,7 @@ function maybeRewriteBody(bodyStr) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return bodyStr;
if (!isGpt5Model(parsed.model)) return bodyStr;
let mutated = false;
// Both fields present (unlikely but possible): drop the legacy one so
// OpenAI doesn't reject for "both specified".
// Drop legacy field if both present, else OpenAI 400s on "both specified".
if ('max_tokens' in parsed && 'max_completion_tokens' in parsed) {
delete parsed.max_tokens;
mutated = true;
@@ -90,15 +47,13 @@ function maybeRewriteBody(bodyStr) {
parsed.max_completion_tokens = parsed.max_tokens;
delete parsed.max_tokens;
mutated = true;
_log('rewrote max_tokens max_completion_tokens for ' + parsed.model);
_log('rewrote max_tokens to max_completion_tokens for ' + parsed.model);
}
// Floor max_completion_tokens at 32K for reasoning headroom. Only raise,
// never lower — if the user explicitly set 100K, keep 100K.
if (typeof parsed.max_completion_tokens === 'number' && parsed.max_completion_tokens < GPT5_MIN_COMPLETION_TOKENS) {
const orig = parsed.max_completion_tokens;
parsed.max_completion_tokens = GPT5_MIN_COMPLETION_TOKENS;
mutated = true;
_log('raised max_completion_tokens ' + orig + ' ' + GPT5_MIN_COMPLETION_TOKENS + ' for ' + parsed.model + ' (reasoning headroom)');
_log('raised max_completion_tokens ' + orig + ' to ' + GPT5_MIN_COMPLETION_TOKENS + ' for ' + parsed.model);
}
return mutated ? JSON.stringify(parsed) : bodyStr;
}
@@ -112,7 +67,6 @@ function _hostFromOpts(opts) {
function patchHttpRequest(orig) {
return function patchedRequest() {
const args = Array.prototype.slice.call(arguments);
// First arg may be a URL string, URL object, or options object.
let opts = args[0];
let host = '';
try {
@@ -125,33 +79,28 @@ function patchHttpRequest(orig) {
return orig.apply(this, args);
}
// Outbound request to OpenAI: intercept body. The Anthropic SDK and
// 9router both call .write(body) then .end(), or .end(body) directly.
let req;
try { req = orig.apply(this, args); } catch (e) { throw e; }
const origWrite = req.write.bind(req);
const origEnd = req.end.bind(req);
const chunks = [];
let isStringMode = null; // null until first chunk; then true=string, false=buffer
let isStringMode = null;
function recordChunk(chunk) {
if (chunk == null) return;
if (typeof chunk === 'string') {
if (isStringMode === false) {
// Mixed mode — fall back: convert prior buffers to string
for (let i = 0; i < chunks.length; i++) chunks[i] = chunks[i].toString('utf8');
}
isStringMode = true;
chunks.push(chunk);
} else if (Buffer.isBuffer(chunk)) {
if (isStringMode === true) {
// Mixed: convert prior strings to buffers
for (let i = 0; i < chunks.length; i++) chunks[i] = Buffer.from(chunks[i], 'utf8');
}
isStringMode = false;
chunks.push(chunk);
} else {
// Unknown shape — abandon interception
throw new Error('unknown-chunk-shape');
}
}
@@ -162,12 +111,10 @@ function patchHttpRequest(orig) {
recordChunk(chunk);
return true;
} catch (_) {
// Abandon interception — pass through immediately and disable buffering.
// Flush anything we'd buffered so far.
try {
for (const c of chunks) origWrite(c);
chunks.length = 0;
} catch (_) { /* ignore */ }
} catch (_) {}
return origWrite.apply(req, [chunk].concat(restArgs));
}
};
@@ -186,19 +133,17 @@ function patchHttpRequest(orig) {
if (req.getHeader && typeof req.getHeader === 'function' && req.getHeader('content-length')) {
req.setHeader('Content-Length', newBuf.length);
}
} catch (_) { /* ignore */ }
} catch (_) {}
return origEnd.call(req, newBuf);
}
// No rewrite — send original body intact
if (chunks.length === 0) return origEnd.apply(req, restArgs);
if (isStringMode === true) return origEnd.call(req, chunks.join(''));
return origEnd.call(req, Buffer.concat(chunks));
} catch (_) {
// Abandon — flush any buffered content + tail chunk
try {
for (const c of chunks) origWrite(c);
chunks.length = 0;
} catch (_) { /* ignore */ }
} catch (_) {}
if (chunk != null) return origEnd.apply(req, [chunk].concat(restArgs));
return origEnd.apply(req, restArgs);
}
@@ -216,13 +161,11 @@ if (!_https.__openswarm_gpt5_patched) {
_http.__openswarm_gpt5_patched = true;
_log('installed https.request + http.request interceptors');
} catch (e) {
// Patch failed — log and continue. 9router will work as normal,
// GPT-5 calls will fail with the same 400 they did before. Never worse.
_log('install failed: ' + (e && e.message ? e.message : String(e)));
}
}
// Also patch global fetch (Node 18+). 9router uses fetch in some paths.
// Node 18+ fetch path; 9router uses fetch in some routes.
if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5_patched) {
try {
const origFetch = globalThis.fetch;
@@ -249,7 +192,7 @@ if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5
if (k.toLowerCase() === 'content-length') newInit.headers[k] = newLen;
}
}
} catch (_) { /* ignore */ }
} catch (_) {}
}
return origFetch.call(this, input, newInit);
}
+177 -109
View File
@@ -73,7 +73,7 @@ def _delete_session_file(session_id: str):
# Patterns that indicate an upstream transient problem (overload / rate limit /
# infra blip) safe to silently retry with backoff. Checked against the
# infra blip); safe to silently retry with backoff. Checked against the
# stringified exception from claude_agent_sdk / Claude CLI.
_TRANSIENT_CAPACITY_PATTERNS = re.compile(
r"(?:\b(?:429|500|502|503|504|529)\b"
@@ -89,7 +89,7 @@ _TRANSIENT_CAPACITY_PATTERNS = re.compile(
)
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
# auth, context-window tier gate). Must NOT retry upgrading, reauthing, or
# auth, context-window tier gate). Must NOT retry; upgrading, reauthing, or
# trimming context is required. The long-context-required variant is what
# Anthropic returns when an OAuth Pro/Max account ships a request whose input
# exceeds the 200K standard tier and would need the "extra usage" tier; the
@@ -151,7 +151,7 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
def _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"
# "Command failed with exit code 1 / Check stderr output for details" ,
# the real cause (rate_limit_error / No pool capacity available / 429
# / overloaded) only surfaces in the subprocess's stderr stream, which
# we capture via the SDK's `stderr` callback and pass in as extra_text.
@@ -165,7 +165,7 @@ def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bo
if _TRANSIENT_CAPACITY_PATTERNS.search(combined):
return True
# Pool-exhaustion copy from the OpenSwarm proxy ("No pool capacity
# available. Try again shortly.") matches the capacity family too.
# available. Try again shortly."); matches the capacity family too.
if re.search(r"no\s+pool\s+capacity", combined, re.IGNORECASE):
return True
return False
@@ -250,7 +250,7 @@ def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
repo with one empty commit so worktree add always has something to
anchor on.
Safe to call on every request does nothing if cwd is already a
Safe to call on every request; does nothing if cwd is already a
valid repo (real project, previous init, or inside a parent repo).
"""
try:
@@ -269,7 +269,7 @@ def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
import subprocess as _sp_git
# Case A: cwd is inside some git repo (possibly parent). Verify
# HEAD resolves. If the enclosing repo is broken (e.g. a stray
# `.git` in $HOME with no commits which makes workspaces
# `.git` in $HOME with no commits; which makes workspaces
# under ~/.openswarm/workspaces/ inherit a broken HEAD), we
# need to init a fresh repo AT cwd so it shadows the parent.
_inside = _sp_git.run(
@@ -288,7 +288,7 @@ def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
return # parent repo is healthy, leave it alone
# Parent repo exists but HEAD is broken.
if os.path.isdir(os.path.join(cwd, ".git")):
# .git is directly here commit to fix it.
# .git is directly here; commit to fix it.
_sp_git.run(
["git", "-c", "user.email=openswarm@local",
"-c", "user.name=OpenSwarm",
@@ -301,7 +301,7 @@ def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
# Init our own repo at cwd so it shadows the broken parent.
# Fall through to Case B.
# Case B: cwd is not a git repo at all (or parent is broken)
# Case B: cwd is not a git repo at all (or parent is broken) ,
# init + empty commit here.
_sp_git.run(
["git", "init", "-q", "-b", "main"],
@@ -383,8 +383,8 @@ class AgentManager:
"""Build the mcp_servers dict for ClaudeAgentOptions from installed MCP tools.
Filtering is two-stage:
1. allowed_tools (mode/session permission) same as before.
2. active_mcps (per-session activation gate) NEW. When this list is
1. allowed_tools (mode/session permission); same as before.
2. active_mcps (per-session activation gate); NEW. When this list is
provided (non-None), only MCP servers whose sanitized name appears
in it are forwarded to the SDK. Empty list means zero MCPs ship.
None means legacy / non-gated path (used by sessions created
@@ -394,7 +394,7 @@ class AgentManager:
invariant "all MCP actions only via ToolSearch": the model can only
reach an MCP server's tools if the user has approved MCPActivate for
that server, which appends to session.active_mcps. The model cannot
bypass this by ignoring prompt instructions the SDK simply receives
bypass this by ignoring prompt instructions; the SDK simply receives
no MCP definition for unactivated servers.
Servers whose every sub-tool is denied are skipped entirely.
@@ -418,7 +418,7 @@ class AgentManager:
server_name = _sanitize_server_name(tool.name)
if active_set is not None and server_name not in active_set:
logger.info(f"[MCP-DEBUG] GATED {server_name}: not in session.active_mcps model must call MCPActivate first")
logger.info(f"[MCP-DEBUG] GATED {server_name}: not in session.active_mcps; model must call MCPActivate first")
continue
if _is_fully_denied(tool):
@@ -482,10 +482,10 @@ class AgentManager:
lines.append(
f" IMPORTANT: When calling tools from this server that require an email "
f"parameter (e.g. user_google_email, user_email), always use "
f"\"{tool.connected_account_email}\" automatically do NOT ask the user."
f"\"{tool.connected_account_email}\" automatically; do NOT ask the user."
)
# Discord guild scoping hard restriction. The bot may technically
# Discord guild scoping; hard restriction. The bot may technically
# be in other servers (across other OpenSwarm users), but this
# specific user only authorized these guild IDs.
if tool.name.lower() == "discord":
@@ -597,7 +597,7 @@ class AgentManager:
return [card.get("browser_id", "") for card in browser_cards.values() if card.get("browser_id")]
def _build_mcp_registry_summary(self, allowed_tools: list[str], active_mcps: list[str]) -> str | None:
"""Compact registry of installed MCP servers one line per server.
"""Compact registry of installed MCP servers; one line per server.
This is the visible surface that drives the activation gate: the model
sees which servers exist and what they're for, but cannot call any
@@ -606,7 +606,7 @@ class AgentManager:
MCPSearch (to find the right one) and then MCPActivate, which fires a
HITL prompt; on approve, the server's tools become callable next turn.
Schemas are NOT included here that's the whole point. A 30-server
Schemas are NOT included here; that's the whole point. A 30-server
registry costs ~1KB; the previous full-schema dump cost ~30-80KB.
"""
all_tools = load_all_tools()
@@ -632,7 +632,7 @@ class AgentManager:
# Fall back to a generic blurb keyed on the tool name so the
# model still has *some* signal to MCPSearch against.
desc = f"{tool.name} integration"
line = f"- `{server_name}` {desc}"
line = f"- `{server_name}`; {desc}"
if server_name in active_set:
active_lines.append(line)
else:
@@ -657,15 +657,15 @@ class AgentManager:
sections.append(
"1. If the user's request needs a server below that isn't Active, "
"your FIRST tool call must be MCPSearch or MCPActivate. Ignore any "
"`mcp__*__authenticate` helpers those are legacy shims; always go "
"`mcp__*__authenticate` helpers; those are legacy shims; always go "
"through MCPActivate."
)
sections.append(
"2. After MCPActivate returns, end the turn a follow-up turn fires "
"2. After MCPActivate returns, end the turn; a follow-up turn fires "
"automatically with the new tools available."
)
sections.append(
"3. Don't ask 'should I activate X?' first MCPActivate already "
"3. Don't ask 'should I activate X?' first; MCPActivate already "
"triggers an approval prompt."
)
sections.append("")
@@ -760,7 +760,7 @@ class AgentManager:
path = cp.get("path", "")
cp_type = cp.get("type", "file")
if not path or not os.path.exists(path):
sections.append(f"[Context: {path} not found]")
sections.append(f"[Context: {path}; not found]")
continue
if cp_type == "file" and os.path.isfile(path):
try:
@@ -770,14 +770,14 @@ class AgentManager:
f"<context_file path=\"{path}\">\n{content}\n</context_file>"
)
except Exception as e:
sections.append(f"[Context: {path} error reading: {e}]")
sections.append(f"[Context: {path}; error reading: {e}]")
elif cp_type == "directory" and os.path.isdir(path):
tree_lines = self._build_dir_tree(path, max_depth=4)
sections.append(
f"<context_directory path=\"{path}\">\n{chr(10).join(tree_lines)}\n</context_directory>"
)
else:
sections.append(f"[Context: {path} type mismatch]")
sections.append(f"[Context: {path}; type mismatch]")
return "\n\n".join(sections)
def _build_dir_tree(self, root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
@@ -826,7 +826,7 @@ class AgentManager:
line += f"\n (MCP server: {server})"
email = tool_to_email.get(name)
if email:
line += f"\n (connected account: {email} use this for any email parameter)"
line += f"\n (connected account: {email}; use this for any email parameter)"
lines.append(line)
return (
@@ -913,7 +913,7 @@ class AgentManager:
# and old user/assistant pairs before the next query() call
# - context_soft_cap_pct (default 0.90): pre-send hard guard. After
# compaction, if still over, LRU-trim active_mcps
# - >= 1.0 hits the proxy/Anthropic 200K ceiling friendly card
# - >= 1.0 hits the proxy/Anthropic 200K ceiling; friendly card
# surfaces from the catch-all
# ------------------------------------------------------------------
@@ -930,7 +930,7 @@ class AgentManager:
"""Programmatic, no-LLM summary of a message slice. Mirrors the
shape of browser_agent._summarize_messages: extracts the original
user task, counts tool calls, captures the last assistant text.
Cheap, deterministic, and never makes a network call so
Cheap, deterministic, and never makes a network call; so
compaction itself adds zero latency to the user's turn.
"""
if not messages:
@@ -988,7 +988,7 @@ class AgentManager:
Returns True if a new summary was produced. Mutates session state:
sets compacted_through_msg_id and emits a context_status event.
Never modifies session.messages originals stay around for the
Never modifies session.messages; originals stay around for the
UI drawer; only the history *sent to the SDK* is trimmed (handled
in _build_history_prefix lookups).
"""
@@ -999,7 +999,7 @@ class AgentManager:
if len(msgs) < 4:
return False
# Summarize everything up to (but not including) the last 6
# messages that window keeps recent intent visible to the
# messages; that window keeps recent intent visible to the
# model so it doesn't lose its train of thought right after
# compaction.
cutoff = max(0, len(msgs) - 6)
@@ -1017,7 +1017,7 @@ class AgentManager:
inline replacement plus the on-disk path (or None if untouched).
Storage is session-scoped under data/sessions/<session_id>/blobs/
never honors caller-supplied paths (defense against path
; never honors caller-supplied paths (defense against path
traversal). The inline replacement keeps the first 4KB so the
model retains some signal about what was returned.
"""
@@ -1044,7 +1044,7 @@ class AgentManager:
head = serialized[:4_000]
replacement = (
f"{head}\n\n"
f"[truncated full output ({len(serialized)} chars) saved to {blob_path}. "
f"[truncated; full output ({len(serialized)} chars) saved to {blob_path}. "
f"Ask the user or run a follow-up tool call if you need the rest.]"
)
return replacement, blob_path
@@ -1114,7 +1114,7 @@ class AgentManager:
# explicitly in builtin_permissions.json). Bash defaults to "ask"
# because every other builtin is sandboxed by domain (Read/Write
# touch files but not the shell, browser tools touch a webview),
# whereas Bash is a full local shell and the agent receives
# whereas Bash is a full local shell; and the agent receives
# untrusted text from MCP tools (Gmail, WebFetch, browsing) that
# can carry prompt injection. Without this, a poisoned email
# could silently `rm -rf` the user. Users who want the old
@@ -1129,7 +1129,7 @@ class AgentManager:
# narrow set of files a prompt-injected agent would use to exfil
# or persist (SSH keys, shell rc files, env files, cloud creds,
# system dirs). Normal in-project / in-workspace / in-Downloads
# edits never match keeping the prompt-fatigue surface tiny.
# edits never match; keeping the prompt-fatigue surface tiny.
import fnmatch as _fnmatch
_SENSITIVE_PATH_PATTERNS = (
@@ -1154,7 +1154,7 @@ class AgentManager:
except Exception:
return False
# Normalize to forward slashes so the patterns match on Windows
# too `os.path.normpath` produces backslashes on Windows
# too; `os.path.normpath` produces backslashes on Windows
# (`C:\Users\eric\.ssh\authorized_keys`), and fnmatch treats
# `/` in the pattern as a literal character. Without this,
# every sensitive-path gate would silently no-op on Windows
@@ -1169,6 +1169,31 @@ class AgentManager:
_PATH_GATED_TOOLS = ("Write", "Edit", "NotebookEdit")
# OS-level scheduling across macOS/Linux/Windows. Agent must
# not install cron entries, launchd plists, Windows scheduled
# tasks, or PowerShell ScheduledTask cmdlets behind the user's
# back; the native OpenSwarm scheduler is the platform-visible
# path. Word-bounded so we don't flag stray strings in echo etc.
import re as _re_sched
_OS_SCHED_RE = _re_sched.compile(
r"\b("
r"crontab|launchctl|launchd|schtasks|systemd-run|"
r"systemctl\s+--user.*timer|at\s+\d|at\s+now|at\s+-f|"
# Windows PowerShell scheduled-task cmdlets:
r"Register-ScheduledTask|New-ScheduledTask|Set-ScheduledTask|"
r"Register-ScheduledJob|New-ScheduledJob"
r")\b",
_re_sched.IGNORECASE,
)
def _looks_like_os_scheduling(tool_input) -> bool:
if not isinstance(tool_input, dict):
return False
cmd = str(tool_input.get("command") or "")
if not cmd:
return False
return bool(_OS_SCHED_RE.search(cmd))
def _extract_target_path(tool_name: str, tool_input) -> str:
if not isinstance(tool_input, dict):
return ""
@@ -1180,7 +1205,17 @@ class AgentManager:
"""Flip a permissive policy to 'ask' when the target path is
sensitive. Defense in depth: even if the user has Write set to
always_allow for productivity, a prompt-injected agent writing
to ~/.ssh/authorized_keys or ~/.zshrc gets surfaced for review."""
to ~/.ssh/authorized_keys or ~/.zshrc gets surfaced for review.
Also: Bash invocations that look like OS-level scheduling
(crontab, launchctl, schtasks, at, systemd-run --on-calendar)
are flipped to 'ask' regardless of permission policy. The
agent should use OpenSwarm's native scheduler for any
recurring task; we don't want it silently installing cron
entries the platform can't see, audit, or stop.
"""
if tool_name == "Bash" and _looks_like_os_scheduling(tool_input):
return "ask"
if policy != "always_allow" or tool_name not in _PATH_GATED_TOOLS:
return policy
if _is_sensitive_write_path(_extract_target_path(tool_name, tool_input)):
@@ -1328,7 +1363,6 @@ class AgentManager:
raw_response = input_data.get("tool_response", "")
# Track individual tool execution
hook_tool_name_early = input_data.get("tool_name", "")
if hook_tool_name_early:
_is_mcp = "__" in hook_tool_name_early
@@ -1360,7 +1394,6 @@ class AgentManager:
slot["total_ms"] = slot.get("total_ms", 0) + elapsed_ms
slot["max_ms"] = max(slot.get("max_ms", 0), elapsed_ms)
# Determine tool success
_tool_success = True
if isinstance(raw_response, str):
_tool_success = not (raw_response.startswith("Error") or raw_response.startswith("Traceback"))
@@ -1469,7 +1502,7 @@ class AgentManager:
# re-expand.
# If a subagent ever needs a parent activation, the user
# must approve it explicitly via MCPActivate inside the
# subagent session same gate as a fresh top-level chat.
# subagent session; same gate as a fresh top-level chat.
sub_session = AgentSession(
id=sub_session_id,
name=sub_name,
@@ -1526,7 +1559,7 @@ class AgentManager:
_, mode_sys_prompt, _ = self._resolve_mode(session.mode)
# MCP servers and their tool inventories are intentionally NOT
# injected into the system prompt. The CLI's deferred-tool pool
# already exposes them by name via ToolSearch eagerly listing
# already exposes them by name via ToolSearch; eagerly listing
# connected MCPs (with account emails, full tool enumerations,
# etc.) here would defeat the deferral and leak knowledge of
# every connected integration into every turn. The model
@@ -1537,7 +1570,7 @@ class AgentManager:
# need to ask which account to use, or pass it explicitly.
# - Discord guild-id "hard restriction" is gone as a prompt
# instruction. Enforce that at the Discord MCP server's
# tool-call layer instead prompt rules are not a security
# tool-call layer instead; prompt rules are not a security
# boundary.
connected_tools_ctx = None
browser_ctx = self._build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids)
@@ -1568,6 +1601,24 @@ class AgentManager:
mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps)
global_settings = load_settings()
# Scheduling nudge: the agent has a ScheduleWorkflow tool +
# CRUD friends, and should proactively offer to schedule
# recurring work via AskUserQuestion. The block below is
# short on purpose so it doesn't crowd the context window;
# the per-tool description carries the full protocol.
schedule_ctx = (
"<scheduling_guidance>\n"
"After completing a substantive task, if the work looks "
"repeatable (the user said 'every', 'each', 'daily', "
"'weekly', 'morning', 'before standup', or you just did "
"the same sequence twice in this session), offer to "
"schedule it. Use AskUserQuestion to confirm cadence, "
"then ScheduleWorkflow to create it. Never reach for "
"crontab, launchctl, or schtasks; always use the native "
"scheduler so the user can see, pause, and edit it. "
"Don't ask after trivial one-off requests.\n"
"</scheduling_guidance>"
)
composed_prompt = self._compose_system_prompt(
global_settings.default_system_prompt,
mode_sys_prompt,
@@ -1576,6 +1627,7 @@ class AgentManager:
browser_ctx,
mcp_registry_ctx,
)
composed_prompt = (composed_prompt + "\n\n" + schedule_ctx) if composed_prompt else schedule_ctx
if session.mode == "view-builder":
# Read the LIVE skill content rather than a frozen-at-import
@@ -1658,6 +1710,27 @@ class AgentManager:
"type": "stdio",
}
# Always-on schedule server. Exposes ScheduleWorkflow +
# CRUD tools so the agent can offer to schedule recurring
# work via the native scheduler (visible, auditable) rather
# than reaching for cron/launchctl. Tool descriptions tell
# the agent to AskUserQuestion FIRST to confirm cadence.
schedule_server_path = os.path.join(
os.path.dirname(__file__), "schedule_mcp_server.py"
)
from backend.auth import get_auth_token as _get_auth_token_sched
mcp_servers["openswarm-schedule"] = {
"command": sys.executable,
"args": [schedule_server_path],
"env": {
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": _get_auth_token_sched(),
"OPENSWARM_PARENT_SESSION_ID": session.id,
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
},
"type": "stdio",
}
# Always-on meta-MCP server. Exposes MCPList / MCPSearch /
# MCPActivate so the model can discover and activate user MCPs at
# runtime. The activation gate (active_mcps filter in
@@ -1682,7 +1755,7 @@ class AgentManager:
# The CLI's built-in WebSearch/WebFetch wraps Anthropic's
# web_search_20250305. For non-Claude primaries the CLI
# delegates execution back to Anthropic via
# ANTHROPIC_SMALL_FAST_MODEL needs an Anthropic credential
# ANTHROPIC_SMALL_FAST_MODEL; needs an Anthropic credential
# or it 401s. We register our DDG-backed MCP only for users
# with no Anthropic path; Anthropic's hosted search is
# higher-quality so we prefer it whenever it's reachable.
@@ -1707,7 +1780,7 @@ class AgentManager:
pass
# When the primary is non-Claude we deliberately don't count
# OpenSwarm Pro as an Anthropic path using the Pro pool for
# OpenSwarm Pro as an Anthropic path; using the Pro pool for
# WebSearch on a GPT/Gemini session would drain it for the
# user's Claude turns. The user's GPT/Gemini subscription
# serves their non-Claude turns at zero cost to us.
@@ -1721,7 +1794,7 @@ class AgentManager:
# connection unless the user separately set up one. The CLI's
# built-in WebSearch delegates to Anthropic Haiku, which falls
# through 9Router to whichever connection serves anthropic/...
# ids usually OpenRouter and 401s. Force the openswarm-web
# ids; usually OpenRouter; and 401s. Force the openswarm-web
# MCP to register so WebSearch always cascades through our own
# /api/web/search (Gemini → OpenAI → DuckDuckGo).
_is_custom_session = _api_type_for_session == "custom"
@@ -1729,7 +1802,7 @@ class AgentManager:
# if the conversation primary IS Claude. Pre-fix: any user
# with an Anthropic key set OR on OpenSwarm Pro skipped the
# openswarm-web MCP registration and the CLI's built-in
# WebSearch routed to Anthropic Haiku which on a Codex
# WebSearch routed to Anthropic Haiku; which on a Codex
# /Gemini session drained the Pro pool's Haiku quota for
# WebSearch calls, even though the conversation primary
# (Codex/Gemini) supports native search via its own credits.
@@ -1770,7 +1843,7 @@ class AgentManager:
"type": "stdio",
}
logger.info(
f"[MCP-DEBUG] Primary {_m} has no reliable native web search "
f"[MCP-DEBUG] Primary {_m} has no reliable native web search; "
f"registering openswarm-web (DDG search + trafilatura fetch, free)"
)
@@ -1808,7 +1881,7 @@ class AgentManager:
if name == "openswarm-web":
# Expose our DDG-backed web tools under an MCP prefix.
# Honor existing WebSearch/WebFetch permission policy
# if the user disabled them in Settings, don't offer
#; if the user disabled them in Settings, don't offer
# the MCP variants either.
for wt in ("WebSearch", "WebFetch"):
policy = _builtin_perms.get(wt, "always_allow")
@@ -1847,14 +1920,14 @@ class AgentManager:
# Tell the model directly which web tools work for this session.
# The Claude Code CLI's deferred-tool registry still advertises bare
# `WebSearch` and `WebFetch` even when we've stripped them above
# `WebSearch` and `WebFetch` even when we've stripped them above ,
# frontier models (Claude/GPT-5/Gemini Pro) intuit the namespaced
# MCP variant from context, but smaller open-source models (gpt-oss
# via Ollama, smaller Llama/Qwen, etc.) thrash on the deferred-tool
# handshake (saw 2+ minutes of repeated `ToolSearch(select:WebSearch)`
# → empty matches → retry). Naming the working tool here cuts that
# to a single direct call. Only injected when (a) we registered the
# web MCP, AND (b) the user hasn't disabled the policy matches
# web MCP, AND (b) the user hasn't disabled the policy; matches
# the same gate the MCP allowlist uses, so disabling WebSearch in
# Settings still wins.
_web_tools_available = _need_web_mcp and (
@@ -1867,21 +1940,21 @@ class AgentManager:
"This session does NOT have the built-in `WebSearch` / "
"`WebFetch` tools (they delegate to Anthropic Haiku, which "
"isn't reachable on this primary). Use the MCP-backed "
"equivalents instead call them DIRECTLY, no ToolSearch "
"equivalents instead; call them DIRECTLY, no ToolSearch "
"step needed:"
)
if "mcp__openswarm-web__WebSearch" in effective_allowed:
_hint_lines.append(
"- `mcp__openswarm-web__WebSearch(query: str, "
"num_results?: int)` DuckDuckGo search."
"num_results?: int)`; DuckDuckGo search."
)
if "mcp__openswarm-web__WebFetch" in effective_allowed:
_hint_lines.append(
"- `mcp__openswarm-web__WebFetch(url: str, prompt?: "
"str)` fetch a URL and return readable text."
"str)`; fetch a URL and return readable text."
)
_hint_lines.append(
"Do not call `ToolSearch(select:WebSearch)` bare "
"Do not call `ToolSearch(select:WebSearch)`; bare "
"`WebSearch` is unavailable on this session and that path "
"will return empty matches."
)
@@ -1891,7 +1964,6 @@ class AgentManager:
f"{composed_prompt}\n\n{_web_hint}" if composed_prompt else _web_hint
)
# Log effective tool lists
google_allowed = [t for t in effective_allowed if "google-workspace" in t]
reddit_allowed = [t for t in effective_allowed if "reddit" in t]
builtin_allowed = [t for t in effective_allowed if not t.startswith("mcp__")]
@@ -1962,14 +2034,14 @@ class AgentManager:
logger.info(f"[MCP-DEBUG] Using direct Anthropic API key (route=api) for {session.model}")
elif _is_pinned_api_route and _api_route_provider == "openai" and getattr(global_settings, "openai_api_key", None):
# Goes through 9Router's Anthropic→OpenAI translator like
# other own-key routes but we point OPENAI_BASE_URL at a
# other own-key routes; but we point OPENAI_BASE_URL at a
# tiny local pass-through (/api/openai-passthrough/v1) that
# renames max_tokens → max_completion_tokens before relaying
# to api.openai.com. OpenAI's GPT-5 family rejects max_tokens
# with HTTP 400, and 9Router 0.3.60 doesn't know about
# max_completion_tokens yet (its CLI<->OpenAI translator
# emits the legacy field). The pin on 0.3.60 is intentional
# (newer 9Router versions regress WebSearch see
# (newer 9Router versions regress WebSearch; see
# nine_router.py comment) so we patch the boundary instead
# of bumping. Pre-fix: every gpt-5.* / gpt-5.* own-key
# session 400'd silently.
@@ -1994,7 +2066,7 @@ class AgentManager:
raise ValueError(
"9Router could not start. Custom OpenAI-compatible "
"providers need 9Router to translate the Anthropic "
"protocol install Node.js and restart the app."
"protocol; install Node.js and restart the app."
)
from backend.apps.agents.providers.registry import _find_custom_provider_for_value
cp = _find_custom_provider_for_value(global_settings, session.model)
@@ -2005,14 +2077,14 @@ class AgentManager:
}
if cp:
# Local OpenAI-compatible servers (LM Studio, Ollama, ...)
# often run with auth disabled the user leaves api_key
# often run with auth disabled; the user leaves api_key
# blank in Settings. The OpenAI-style SDK insists on a
# non-empty key; substitute a harmless placeholder so the
# CLI can issue requests. Servers that DO check auth always
# have a real key configured.
env["OPENAI_API_KEY"] = (cp.api_key or "").strip() or "no-auth-required"
env["OPENAI_BASE_URL"] = (cp.base_url or "")
# Pin subagent ids without these, CLI's default Haiku 4.5
# Pin subagent ids; without these, CLI's default Haiku 4.5
# gets sent to the custom provider and 404s.
if global_settings.anthropic_api_key:
env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6"
@@ -2054,7 +2126,7 @@ class AgentManager:
if not _9r_running():
raise ValueError(
"9Router could not start. OpenRouter routing requires "
"Node.js install it and restart the app, or pick a "
"Node.js; install it and restart the app, or pick a "
"model that uses a direct API key (Anthropic, OpenAI, "
"or Google AI Studio)."
)
@@ -2139,12 +2211,12 @@ class AgentManager:
env["ANTHROPIC_SMALL_FAST_MODEL"] = _small_model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = _small_model
logger.info(
f"[MCP-DEBUG] 9Router direct subagent_model={_sub_model}, small_fast={_small_model}"
f"[MCP-DEBUG] 9Router direct; subagent_model={_sub_model}, small_fast={_small_model}"
)
# ENABLE_TOOL_SEARCH=auto: without it, CLI's tengu_defer_all_bn4
# Statsig flag defers 16 tools with no way to load them on non-
# Anthropic networks. "auto" eagerly loads tools when schema
# budget fits in ~10% of context. Don't pass --bare sets
# budget fits in ~10% of context. Don't pass --bare; sets
# CLAUDE_CODE_SIMPLE=1 which strips the system prompt scaffolding.
env["ENABLE_TOOL_SEARCH"] = "auto"
options_kwargs["env"] = env
@@ -2179,7 +2251,7 @@ class AgentManager:
"preset": "claude_code",
}
# exclude_dynamic_sections=True moves cwd/git/OS grounding out of
# the cached prefix and into the first user message unlocks
# the cached prefix and into the first user message; unlocks
# Anthropic prompt cache (~80% input-token cut, 13-31% faster TTFT).
# Trade-off: grounding freezes at turn 1.
if composed_prompt:
@@ -2209,7 +2281,7 @@ class AgentManager:
try:
level = getattr(session, "thinking_level", "auto") or "auto"
# Trivially short prompts ("hi", "thanks") don't benefit from
# 5-30s of hidden reasoning. Override per-turn only session
# 5-30s of hidden reasoning. Override per-turn only; session
# setting is untouched so the UI pill keeps reflecting the
# user's choice.
_prompt_len = len((prompt or "").strip())
@@ -2274,7 +2346,7 @@ class AgentManager:
prompt_content.insert(0, {"type": "text", "text": history})
# Compaction trigger (Phase 2). Driven by live ctx_used ratio
# rather than turn count fires when input_tokens/context_window
# rather than turn count; fires when input_tokens/context_window
# crosses session.compact_threshold_pct (default 0.65). Cheap,
# programmatic summarization (no aux LLM call) so this adds
# zero latency on the user's turn.
@@ -2296,7 +2368,7 @@ class AgentManager:
# Use the most recent measurement (the prior turn's
# input_tokens) as the estimate. Conservative because the
# current turn's user prompt + any new history adds on top
# but the first turn of a fresh session has tokens=0 so
#; but the first turn of a fresh session has tokens=0 so
# we only act once we've seen real numbers.
_est_tokens = session.tokens.get("input", 0)
_hard_cap = int(session.context_window * session.context_soft_cap_pct)
@@ -2354,7 +2426,7 @@ class AgentManager:
_turn_thinking_text_parts: list[str] = []
_turn_tool_count: int = 0
_turn_started_ts: float | None = None
# Wall-clock turn duration (ms) covers thinking + tool
# Wall-clock turn duration (ms); covers thinking + tool
# execution + assistant text. Updated continuously as the
# turn unfolds. Used for the "Thought for Ns" segment so
# the duration reflects the entire user-visible wait, not
@@ -2363,14 +2435,14 @@ class AgentManager:
# Total output tokens across every AssistantMessage in the
# turn (thinking + visible text + tool-call JSON args). The
# consolidated thinking pill's `tokens` segment uses this
# rather than thinking-text-only chars/3.6 answers the
# rather than thinking-text-only chars/3.6; answers the
# question "how much work did the model produce on this
# turn" honestly. Populated from each AssistantMessage's
# usage.output_tokens; fallback heuristic kicks in only
# when usage is absent.
_turn_output_tokens: int = 0
# Running char counts for the streaming portions of the
# turn used to grow the token estimate while assistant
# turn; used to grow the token estimate while assistant
# text and tool-call JSON args are still streaming, BEFORE
# the SDK has emitted a final usage.output_tokens count
# for those blocks. Once the AssistantMessage lands with
@@ -2402,7 +2474,7 @@ class AgentManager:
_first_event = True
# True between the first non-ResultMessage of a turn and the
# following ResultMessage; False at turn boundaries. The retry
# layer below only retries at boundaries resuming mid-turn via
# layer below only retries at boundaries; resuming mid-turn via
# sdk_session_id would risk duplicating user-visible output.
_current_turn_emitted = False
@@ -2417,7 +2489,7 @@ class AgentManager:
async def _emit_consolidated_thinking(force_provider_unavailable: bool = False) -> None:
"""Build the running aggregate Message and broadcast it.
Safe to call multiple times uses a stable per-turn id
Safe to call multiple times; uses a stable per-turn id
so the frontend dedupes by id and updates the bubble in
place.
@@ -2425,7 +2497,7 @@ class AgentManager:
1. Reasoning text exists (Anthropic happy path).
2. Upstream provider reported reasoning tokens via
9Router (best-effort path for GPT/Gemini).
3. force_provider_unavailable=True caller has
3. force_provider_unavailable=True; caller has
determined this turn went through a translator that
doesn't carry reasoning content (cx/ or gc/), and
the user should see a "provider doesn't expose
@@ -2461,7 +2533,7 @@ class AgentManager:
and not force_provider_unavailable
):
# No text, no upstream signal, and caller didn't
# ask for the unavailable-pill nothing to show.
# ask for the unavailable-pill; nothing to show.
return
joined_text = "\n".join(_turn_thinking_text_parts)
# Total turn output token estimate. Combines two sources:
@@ -2471,7 +2543,7 @@ class AgentManager:
# - chars/3.6 heuristic over the running streams of
# thinking + assistant-text + tool-input JSON
# (covers in-flight blocks the SDK hasn't billed
# yet i.e. the answer the user is currently
# yet; i.e. the answer the user is currently
# reading).
# Take the max so the number doesn't visually shrink as
# the SDK's authoritative count overtakes our running
@@ -2521,23 +2593,23 @@ class AgentManager:
pass
if _turn_thinking_msg_id is None:
_turn_thinking_msg_id = uuid4().hex
# Combined token total for the pill input + output for
# Combined token total for the pill; input + output for
# the parent turn PLUS any work delegated to subagents
# (browser agents, invoke-agent forks) and tool MCP
# servers that produced their own usage on this turn.
# The user-visible answer to "how big is this turn" is
# the all-in sum, not just the primary's output. We sum
# every reachable source:
# - parent's input (session.tokens["input"]
# - parent's input (session.tokens["input"] ,
# ResultMessage.usage at line ~2886)
# - parent's output (session.tokens["output"] same
# - parent's output (session.tokens["output"]; same
# ResultMessage)
# - every direct sub-session whose parent_session_id
# points at this session (browser agents, sub-agent
# forks, invoke-agent calls book their own usage at
# subprocess return time agent_manager.py:1365 +
# subprocess return time; agent_manager.py:1365 +
# browser_agent.py:1000-1001)
# This mirrors how billing accumulates per-turn caches,
# This mirrors how billing accumulates per-turn; caches,
# tool MCP servers that talk to LLMs (e.g. summarizers),
# and subagent reasoning all show up under the parent's
# "session.tokens" once their result lands.
@@ -2566,7 +2638,7 @@ class AgentManager:
pass
# Fall back to cumulative if the baseline wasn't captured
# (degenerate empty turn better than showing zero).
# (degenerate empty turn; better than showing zero).
if _turn_baseline_captured:
_parent_in = max(0, _cum_in - _turn_baseline_session_in)
_parent_out = max(0, _cum_out - _turn_baseline_session_out)
@@ -2655,7 +2727,7 @@ class AgentManager:
else:
_current_turn_emitted = True
# Stamp the turn's wall-clock start at the FIRST
# non-Result message we see this is when the
# non-Result message we see; this is when the
# user actually started waiting. We use the same
# timestamp as the basis for "Thought for Ns"
# so the duration covers thinking + tool exec
@@ -2687,7 +2759,7 @@ class AgentManager:
# translator strips reasoning content (cx/, gc/,
# ag/, gemini/). Without this, the pill emits
# at turn end and lands BELOW the assistant
# text in session.messages visually wrong.
# text in session.messages; visually wrong.
# Pre-emitting here gives the pill the same
# ordering as Anthropic's natural streaming
# path. Updates in place at turn end via the
@@ -2706,7 +2778,6 @@ class AgentManager:
logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}")
_first_event = False
# Log system messages (MCP server status, errors, etc.)
if isinstance(message, SystemMessage):
raw = message.__dict__ if hasattr(message, '__dict__') else str(message)
logger.info(f"[MCP-DEBUG] SystemMessage: {raw}")
@@ -2742,7 +2813,7 @@ class AgentManager:
# (GPT-5.3 Codex, Gemini 3 Pro/Flash, Claude
# with extended thinking). Rendered as a
# collapsible "thinking" message in the UI via
# the existing stream infrastructure the
# the existing stream infrastructure; the
# frontend already handles role="thinking" for
# the DynamicIsland/agent card rendering.
thinking_msg_id = uuid4().hex
@@ -2766,7 +2837,7 @@ class AgentManager:
# consolidated thinking pill. The
# AssistantMessage path (further down)
# ALSO increments _turn_tool_count when
# ToolUseBlocks fully arrive but for
# ToolUseBlocks fully arrive; but for
# OpenAI/Gemini through 9Router the
# AssistantMessage envelope is sometimes
# incomplete, so this stream-level count
@@ -2774,7 +2845,7 @@ class AgentManager:
# segment renders cross-provider. To
# avoid double-counting we DON'T also
# increment on AssistantMessage when
# this code path already fired see
# this code path already fired; see
# the dedupe at the AssistantMessage
# block below.
_turn_tool_count += 1
@@ -2824,7 +2895,7 @@ class AgentManager:
# If this was a thinking block, accumulate
# elapsed_ms server-side. We don't include
# per-block elapsed/tokens on the WS event
# the pill stays in "Thinking…" until the
#; the pill stays in "Thinking…" until the
# AssistantMessage lands carrying the per-turn
# aggregate values.
if index in _thinking_block_starts:
@@ -2861,7 +2932,7 @@ class AgentManager:
thinking_text = getattr(block, "thinking", None) or getattr(block, "text", None) or ""
if thinking_text:
new_thinking_parts.append(thinking_text)
# Try multiple field-name variants SDK
# Try multiple field-name variants; SDK
# versions and 9Router translations have
# used `signature`, `thoughtSignature`,
# and `thought_signature` over time.
@@ -2903,7 +2974,7 @@ class AgentManager:
# higher count.
if new_thinking_parts:
_turn_thinking_text_parts.extend(new_thinking_parts)
# Latch the most recent thoughtSignature Gemini
# Latch the most recent thoughtSignature; Gemini
# only validates against the LATEST one in the
# conversation history, so older signatures from
# earlier think-steps in the same turn are
@@ -2959,7 +3030,7 @@ class AgentManager:
if "codex/" in _lower_text or "[codex" in _lower_text:
friendly = (
"GPT subscription token expired. Open Settings → Models and click "
"Reconnect on the OpenAI / GPT row to refresh should take ~10s, "
"Reconnect on the OpenAI / GPT row to refresh; should take ~10s, "
"then send your message again."
)
reason = "codex_token_expired"
@@ -3024,7 +3095,7 @@ class AgentManager:
# ResultMessage carries the AUTHORITATIVE per-turn
# output_tokens count. Some providers (notably
# OpenAI/Gemini through 9Router) only populate
# `usage.output_tokens` here not on individual
# `usage.output_tokens` here; not on individual
# AssistantMessages. Fold this into the running
# turn aggregate BEFORE emitting the final
# consolidated thinking message, so the bubble's
@@ -3034,7 +3105,7 @@ class AgentManager:
_result_usage = getattr(message, "usage", None) or {}
if isinstance(_result_usage, dict):
_result_out = int(_result_usage.get("output_tokens", 0) or 0)
# Take the max if individual
# Take the max; if individual
# AssistantMessages already summed to a
# larger number we trust that; otherwise
# ResultMessage's count fills the gap.
@@ -3144,7 +3215,7 @@ class AgentManager:
# provider (Ollama Cloud, Together, Groq,
# local LMs, etc.). Pricing is unknowable
# without per-provider rate tables that
# would rot fast zero out instead of
# would rot fast; zero out instead of
# showing the SDK's Anthropic-rate
# estimate, which is meaningless here.
_free_route = True
@@ -3233,7 +3304,7 @@ class AgentManager:
# we wait and restart. On resume the CLI re-runs the
# last turn from scratch (Anthropic doesn't persist
# in-progress responses), so the partial assistant
# text / tool call we emitted is now orphaned cap
# text / tool call we emitted is now orphaned; cap
# it with stream_end and start the fresh turn under a
# new message id.
if stream_text_msg_id:
@@ -3291,8 +3362,8 @@ class AgentManager:
# Long-context-required 429 fork: surface a friendly overflow event
# so the frontend can render an actionable card ("Switch to Chat
# mode" / "Start a fresh chat") instead of a raw error blob. The
# user can't recover by waiting this is a tier-gate, not a rate
# limit so the UX matters.
# user can't recover by waiting; this is a tier-gate, not a rate
# limit; so the UX matters.
try:
_stderr_tail = "\n".join(_stderr_buffer[-50:])
except Exception:
@@ -3301,7 +3372,7 @@ class AgentManager:
friendly_msg = (
"This conversation has grown too large for your account's "
"standard context window. Long-context requests require an "
"upgraded tier switch to Chat mode or start a fresh chat "
"upgraded tier; switch to Chat mode or start a fresh chat "
"to continue."
)
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
@@ -3319,16 +3390,16 @@ class AgentManager:
})
elif _is_auth_error(e, extra_text=_stderr_tail):
# Three sub-cases the user can hit, with distinct fixes:
# 1. "No credentials for provider: claude" user picked a
# 1. "No credentials for provider: claude"; user picked a
# -cc route but doesn't have Claude Pro/Max connected
# via 9Router. Tell them to either connect Claude
# Pro/Max OR pick a non--cc model.
# 2. OpenSwarm Pro 401 bearer expired. Reconnect.
# 3. Anthropic API key 401 wrong key. Re-enter.
# 2. OpenSwarm Pro 401; bearer expired. Reconnect.
# 3. Anthropic API key 401; wrong key. Re-enter.
_model = (session.model or "").lower()
_combined = f"{e!s}\n{_stderr_tail}".lower()
# Codex/OpenAI subscription tokens rotate every ~2-3
# minutes the user sees the rotation window as a 401
# minutes; the user sees the rotation window as a 401
# with "reset after 1m 59s" or similar. Don't ask them to
# reconnect; just tell them to wait it out and retry.
if (
@@ -3336,7 +3407,7 @@ class AgentManager:
and ("authentication token is expired" in _combined or "authentication token has expired" in _combined or "401" in _combined)
):
friendly_msg = (
"GPT subscription token just rotated this is "
"GPT subscription token just rotated; this is "
"automatic and resets every couple minutes. Send "
"your message again in ~1 minute and it'll go "
"through. (No need to reconnect anything.)"
@@ -3631,7 +3702,7 @@ class AgentManager:
# Fire a background aux LLM call to generate a 3-6 word verb-phrase
# describing this turn ("Auditing the pull request", "Drafting your
# email"). The narrator pill swaps from its heuristic verb to this
# label as soon as it lands usually ~500ms-1s into the turn,
# label as soon as it lands; usually ~500ms-1s into the turn,
# which is exactly when "Thinking…" starts feeling generic.
# Provider-agnostic via resolve_aux_model. Non-blocking; failure
# is silent and the heuristic stays.
@@ -3643,15 +3714,12 @@ class AgentManager:
except Exception:
pass
# Track context attachment patterns
if context_paths or attached_skills or images or forced_tools:
pass
# Track skill usage
for skill in (attached_skills or []):
pass
# Track first message sophistication
is_first_message = sum(1 for m in session.messages if m.role == "user") == 1
if is_first_message:
pass
@@ -3876,7 +3944,7 @@ class AgentManager:
Fires in the background while the actual turn streams. The pill
renderer swaps from its heuristic verb to this label as soon as it
arrives, then back to the heuristic if the call fails. Cost is
~$0.0001 per turn at Haiku tier trivial vs the perceived-quality
~$0.0001 per turn at Haiku tier; trivial vs the perceived-quality
win.
Provider-agnostic per memory rule: uses `resolve_aux_model`
@@ -3953,13 +4021,13 @@ class AgentManager:
Skips silently if the session doesn't exist, isn't on Anthropic,
or has no Anthropic credentials. Skips if a real request is
already in flight on this session Anthropic permits parallel
already in flight on this session; Anthropic permits parallel
requests but it just wastes the warm.
"""
session = self.sessions.get(session_id)
if not session:
return
# If a real run is in flight, the cache will be warmed by it
# If a real run is in flight, the cache will be warmed by it ,
# firing again is wasted tokens.
existing = self.tasks.get(session_id)
if existing and not existing.done():
@@ -4121,7 +4189,7 @@ class AgentManager:
doesn't have one. Two paths previously sent close-events without
a timestamp and made the cloud unable to compute duration_ms
(which surfaced as duration_ms=null on 90% of session.ended events
browser-agent and shutdown paths in particular):
; browser-agent and shutdown paths in particular):
1. browser_agent.py calls this without setting closed_at.
2. shutdown_all_sessions() clears closed_at to None for the
@@ -4129,7 +4197,7 @@ class AgentManager:
Fix is here at the bottleneck rather than at every caller so we
can't miss a future call site. The on-disk session JSON keeps its
original (possibly None) closed_at only the cloud-bound dump
original (possibly None) closed_at; only the cloud-bound dump
gets the synthesized timestamp.
"""
if close_reason == "mock" or getattr(session, "_mock_run", False):
+18 -77
View File
@@ -11,12 +11,7 @@ import logging
logger = logging.getLogger(__name__)
# In-flight dedup map for generate-group-meta. Keyed by (session_id, group_id).
# When the frontend issues N concurrent requests for the same group (which it
# can during heavy streaming), we only fire ONE upstream Anthropic call and
# return the same Future to all callers. Eliminates the 429 thundering herd
# without changing retry/fallback semantics — each unique (session, group)
# still gets its full retry budget, just not multiplied by N callers.
# Dedup concurrent generate-group-meta calls; collapses the 429 thundering herd by sharing one upstream Future per (session, group).
_group_meta_inflight: dict[tuple[str, str], asyncio.Future] = {}
@asynccontextmanager
@@ -32,7 +27,6 @@ async def agents_lifespan():
agents = SubApp("agents", agents_lifespan)
# REST Endpoints
@agents.router.get("/sessions")
async def list_sessions(dashboard_id: str = ""):
@@ -57,12 +51,7 @@ async def send_message(session_id: str, body: dict):
if not prompt:
raise HTTPException(status_code=400, detail="prompt is required")
# Pre-flight MCP suggestion (Phase 3, Layer N). Runs in parallel with
# the agent launch path — if it produces suggestions, they're
# surfaced inline in the chat via agent:mcp_suggestions WS event.
# Fails open: any error from the classifier is swallowed and the
# agent proceeds normally. The classifier is short-circuited for
# obviously-local prompts (greetings, shell commands, file paths).
# Run MCP-suggestion classifier in parallel with the agent launch; fails open.
try:
from backend.apps.agents.mcp_preflight import run_preflight
from backend.apps.agents.ws_manager import ws_manager as _ws
@@ -79,7 +68,6 @@ async def send_message(session_id: str, body: dict):
except Exception:
pass
# Non-blocking — don't gate the agent on the classifier.
import asyncio as _asyncio
_asyncio.create_task(_emit_preflight())
except Exception:
@@ -146,11 +134,7 @@ async def generate_group_meta(session_id: str, body: dict):
if not group_id or not tool_calls:
raise HTTPException(status_code=400, detail="group_id and tool_calls are required")
# In-flight dedup. If an identical request is already running, await its
# result instead of firing another Anthropic call. This is the entire fix
# for the 429 storm we were seeing — N concurrent identical requests
# collapse to 1 upstream call. Refinement requests bypass dedup since
# they may legitimately want fresh results with different inputs.
# Dedup: share an in-flight Future across callers; refinement requests bypass since they may want fresh results.
is_refinement = body.get("is_refinement", False)
key = (session_id, group_id)
if not is_refinement:
@@ -159,8 +143,7 @@ async def generate_group_meta(session_id: str, body: dict):
try:
return await existing
except Exception:
# If the in-flight call failed, fall through and try again
# ourselves rather than propagating someone else's error.
# In-flight call failed; retry ourselves rather than propagate someone else's error.
pass
future: asyncio.Future = asyncio.get_event_loop().create_future()
@@ -182,7 +165,6 @@ async def generate_group_meta(session_id: str, body: dict):
future.set_exception(e)
raise
finally:
# Always clear our slot if we own it, so the next request runs fresh.
if not is_refinement and _group_meta_inflight.get(key) is future:
_group_meta_inflight.pop(key, None)
@@ -252,12 +234,7 @@ async def resume_session(session_id: str):
@agents.router.post("/sessions/{session_id}/warm-cache")
async def warm_session_cache(session_id: str):
"""Fire a max_tokens=1 dummy request through the agent path so
Anthropic processes the system+tools prefix and writes the prompt
cache. The next real user turn lands a cache hit instead of paying
cold-start TTFT. Non-blocking, fire-and-forget on the frontend.
Returns 200 even on failure (best-effort).
"""
"""Fire a max_tokens=1 dummy request to prime the Anthropic prompt cache; best-effort."""
try:
await agent_manager.warm_prompt_cache(session_id)
except Exception:
@@ -265,10 +242,6 @@ async def warm_session_cache(session_id: str):
return {"ok": True}
# ---------------------------------------------------------------------------
# 9Router / Subscription endpoints
# ---------------------------------------------------------------------------
@agents.router.get("/subscriptions/status")
async def subscriptions_status():
"""Check if 9Router is running and list connected providers."""
@@ -277,8 +250,7 @@ async def subscriptions_status():
return {"running": False, "providers": [], "models": []}
connections = await get_providers()
models = await get_models()
# Frontend consumers (OnboardingModal, Settings) read
# `data.providers.connections` — preserve that envelope here.
# Frontend reads data.providers.connections; preserve the envelope.
return {"running": True, "providers": {"connections": connections}, "models": models}
@@ -295,11 +267,7 @@ async def subscriptions_connect(body: dict):
if not is_running():
raise HTTPException(status_code=503, detail="9Router not available. Please install Node.js.")
# If reconnecting a primary lane (e.g. gemini-cli), drop its cascade
# siblings first. The registry prefers antigravity over gemini-cli
# when both are present, so a stale antigravity token would keep
# 400ing even after gemini-cli refreshes. Wiping the sibling forces
# the registry onto the freshly reconnected lane.
# Reconnecting gemini-cli must wipe antigravity; registry prefers AG and a stale AG token would 400 after gemini-cli refreshes.
cascade = _PROVIDER_CASCADE_REMOVES.get(provider, [])
if cascade:
try:
@@ -310,7 +278,6 @@ async def subscriptions_connect(body: dict):
try:
result = await start_oauth(provider)
# For auth_code flows, store pending state so the callback can exchange
if result.get("flow") == "authorization_code" and result.get("state"):
from backend.main import _pending_oauth
_pending_oauth[result["state"]] = {
@@ -384,8 +351,7 @@ async def subscriptions_models():
@agents.router.post("/probe-model")
async def probe_model(body: dict):
"""1-token health probe. Returns {ok, latency_ms} or {ok:false, error}
or {ok:true, skipped:true} when the route's ambiguous (silent beats wrong)."""
"""1-token health probe; returns latency or skipped when the route is ambiguous (silent beats wrong)."""
import time as _time
short_name = (body or {}).get("model") or ""
if not short_name:
@@ -444,8 +410,7 @@ async def probe_model(body: dict):
except Exception as e:
msg = str(e).splitlines()[0] if str(e) else type(e).__name__
low = msg.lower()
# Suppress transients chat will retry naturally and probe-time aliasing
# 404s often differ from how the chat path resolves the same id.
# Suppress transients: chat retries naturally and probe-time alias 404s often differ from chat resolution.
if any(s in low for s in (
"timeout", "timed out",
"connection reset", "connection aborted",
@@ -474,7 +439,7 @@ async def list_models():
try:
conns = await _9r_providers()
raw_providers = {c.get("provider", "") for c in conns if c.get("isActive") or c.get("testStatus") == "active"}
# 9Router uses "claude"; our models use api="anthropic" — map across.
# 9Router uses "claude"; our models use api="anthropic". Map across.
_9R_TO_API = {
"claude": "anthropic",
"codex": "codex",
@@ -486,8 +451,7 @@ async def list_models():
logger.debug(f"Failed to fetch 9Router providers: {e}")
def _serialize(models: list[dict]) -> list[dict]:
# Native models. Tiers describe the model itself; billing_kind
# describes the user's wallet for it. Pricing is shown only for paid.
# Tiers describe the model; billing_kind describes the wallet. Pricing shown only for paid.
from backend.apps.agents.providers.registry import (
COST_PER_1M_TOKENS,
compute_tiers,
@@ -518,7 +482,7 @@ async def list_models():
"reasoning": bool(m.get("reasoning", False)),
"input_cost_per_1m": input_cost,
"output_cost_per_1m": output_cost,
# Strict subscription doesn't count. Pickerside uses Subscription chip.
# Strict free; subscriptions show via the picker's Subscription chip.
"is_free": billing_kind == "free",
"billing_kind": billing_kind,
"tiers": list(tiers),
@@ -539,8 +503,7 @@ async def list_models():
cc_variants = [m for m in anthropic_models if m.get("route") == "cc"]
api_variants = [m for m in anthropic_models if m.get("route") == "api"]
# Pro mode shows two groups (Pro proxy + Anthropic alternates via cc/api);
# own-key mode collapses to one Anthropic group using adaptive routing.
# Pro mode splits into Pro proxy + Anthropic alternates; own-key collapses to one adaptive group.
notes: list[dict] = []
if is_openswarm_pro:
result["OpenSwarm Pro"] = _serialize(adaptive)
@@ -605,8 +568,7 @@ async def list_models():
if visible:
result[provider_name] = visible
# OR catalog fetched straight from openrouter.ai (independent of 9Router
# boot state) so picker populates the moment a key lands.
# Fetch OpenRouter catalog directly (independent of 9Router) so picker fills the moment a key lands.
if has_openrouter_key:
try:
from backend.apps.agents.providers.registry import fetch_openrouter_models
@@ -654,10 +616,7 @@ async def list_models():
entries = sorted(by_vendor[vendor], key=lambda x: x["label"].lower())
result[f"OpenRouter · {pretty}"] = entries
# User-configured custom OpenAI-compatible providers (Ollama Cloud, Together, etc).
# Each provider becomes its own group in the picker; each model is addressed via
# the `custom/<slug>/<model_id>` value, which `_find_builtin_model` synthesises
# into a route='api' / api='custom' entry at request time.
# Custom OpenAI-compatible providers (Ollama Cloud, Together, etc); addressed via custom/<slug>/<model_id>.
from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup
for cp in (getattr(settings, "custom_providers", None) or []):
cp_name = (getattr(cp, "name", "") or "").strip()
@@ -692,27 +651,14 @@ async def list_models():
return {"models": result, "notes": notes}
# Google's two OAuth lanes (gemini-cli and antigravity) share user-facing
# meaning (both = "Google subscription") but 9Router treats them as
# separate connections with independent token lifecycles. The registry
# prefers `ag/` over `gc/` whenever AG is active because AG bypasses the
# thoughtSignature validator that breaks multi-step tool turns. That
# preference becomes a footgun when AG's token expires silently: the
# user reconnects "Google", only gemini-cli refreshes, and every request
# still routes through the stale AG token -> 400 Invalid argument.
#
# Cascade is one-directional. gemini-cli is the primary lane the UI
# exposes; operations on it sweep antigravity too. Direct operations on
# antigravity (e.g. an explicit AG opt-in/out path) MUST NOT cascade
# back to gemini-cli or we'd nuke the user's main Google connection.
# gemini-cli and antigravity are two Google OAuth lanes; registry prefers AG, so we cascade-wipe AG when reconnecting gemini-cli to avoid stale-AG 400s. One-directional: AG operations MUST NOT cascade back.
_PROVIDER_CASCADE_REMOVES: dict[str, list[str]] = {
"gemini-cli": ["antigravity"],
}
async def _delete_provider_connections(providers: list[str]) -> int:
"""Delete all 9Router connections whose provider is in the given list.
Returns the count actually removed. Silent if 9Router is unreachable."""
"""Delete 9Router connections in `providers`; returns count removed, silent on 9Router unreachable."""
import httpx
from backend.apps.nine_router import NINE_ROUTER_API, get_providers
try:
@@ -733,12 +679,7 @@ async def _delete_provider_connections(providers: list[str]) -> int:
@agents.router.post("/subscriptions/disconnect")
async def subscriptions_disconnect(body: dict):
"""Disconnect a subscription provider via 9Router.
For Google's paired lanes (gemini-cli + antigravity), wipe BOTH so a
subsequent reconnect lands on a clean slate instead of resurrecting
a stale sibling.
"""
"""Disconnect a subscription provider via 9Router; cascades-wipe Google's paired lanes."""
provider = body.get("provider", "")
if not provider:
raise HTTPException(status_code=400, detail="provider required")
+20 -81
View File
@@ -1,20 +1,4 @@
"""Lightweight Anthropic-format HTTP proxy.
When a user is on openswarm-pro with a non-Claude primary (GPT/Gemini/etc.),
the Claude Code CLI needs a single `ANTHROPIC_BASE_URL` that can serve BOTH:
1. the primary model calls (e.g. `cx/gpt-5` → must go to 9Router)
2. auxiliary Claude calls for subagents, WebSearch delegation
(e.g. `claude-haiku-4-5` → must go to OpenSwarm Pro's cloud proxy)
9Router doesn't know about OpenSwarm Pro, and we don't want to maintain a
custom 9Router provider-node for that. This proxy splits requests by the
`model` field in the body and forwards each to the correct upstream.
Mounted at `/api/anthropic-proxy`. Set `ANTHROPIC_BASE_URL` to
`http://127.0.0.1:<backend-port>/api/anthropic-proxy` in the CLI env for
Pro users with non-Claude primaries.
"""
"""Anthropic-format HTTP proxy splitting requests by model field; primary to 9Router, aux Claude to Pro proxy."""
import json
import logging
@@ -48,42 +32,27 @@ _CLAUDE_MODEL_PREFIXES = (
_GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/")
# Bare-model patterns that resolve to Gemini's native API (gemini-3-flash-api,
# gemini-3.1-pro-api, gemini-3.1-flash-lite-api, etc. — when user supplies own
# Google API key in Settings → Models). These bypass our `gemini/` prefix so
# the prefix-only check above misses them; we match on the bare-name shape
# here too so $schema scrubbing fires for own-key Gemini sessions.
# Pre-fix: 8/8 own-key Gemini sessions in production failed with 400 because
# JSON Schema's $schema field leaked into Google's tools[].function_declarations
# payload. (See raw_payloads where status=error on every gemini-*-api session.)
# Own-key Gemini ("gemini-3-flash-api" etc.) skips the gemini/ prefix; match bare names so $schema scrub still fires.
_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
# Fields Gemini's function_declarations validator rejects. 9Router 0.3.60's
# translator strips allOf/anyOf/oneOf/const-toplevel/required but misses
# these. Each one we've seen Gemini 400 on in production with "Unknown
# name 'X' at request.tools[N].function_declarations[N].parameters.…"
# Keys 9Router 0.3.60 misses that Gemini's function_declarations validator 400s on. Each was caught in prod.
_GEMINI_FORBIDDEN_SCHEMA_KEYS = {
# JSON-Schema metadata fields Gemini's stricter validator doesn't accept.
"$schema",
"$id", # ag/gemini-3.1-pro-high session, 2026-05-08
"$ref", # JSON-Schema reference; Gemini wants inlined types
"$defs", # ditto
"definitions", # legacy alias for $defs
# Constraint fields Gemini doesn't implement.
"$id",
"$ref",
"$defs",
"definitions",
"additionalProperties",
"propertyNames",
"patternProperties",
"exclusiveMinimum",
"exclusiveMaximum",
"const", # nested const leaks through 9Router's top-level-only strip.
# Anthropic-specific tool-call hints not part of vanilla JSON Schema.
# Anthropic's CLI emits these on tools that benefit from response
# priming; Gemini's validator rejects all unknown keys.
"prefill", # ag/gemini-3.1-pro-high session, 2026-05-08
"enumTitles", # human-readable enum labels; OpenAI-only convention
"title", # safe to keep usually but Gemini sometimes rejects under nested arrays
"examples", # JSON-Schema 2019-09 keyword Gemini doesn't honor
"default", # often allowed but rejected in nested array.items
"const",
"prefill",
"enumTitles",
"title",
"examples",
"default",
"readOnly",
"writeOnly",
"deprecated",
@@ -106,28 +75,15 @@ def _scrub_gemini_schema(node):
return node
# Models that REQUIRE max_completion_tokens instead of max_tokens.
# OpenAI's GPT-5.x family (gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.3-codex,
# etc.) introduced this in late 2025 — the legacy `max_tokens` field returns
# a 400 "Unsupported parameter: 'max_tokens' is not supported with this
# model. Use 'max_completion_tokens' instead." Anthropic's CLI / SDK still
# emits `max_tokens` because that's the Anthropic-format wire shape; we
# rename it on the way out for OpenAI-routed GPT-5 models.
# GPT-5.x rejects max_tokens; needs max_completion_tokens. Anthropic-format wire still emits max_tokens; we rename on the way out.
_OPENAI_MAX_COMPLETION_TOKENS_MODELS = ("gpt-5",)
def _is_openai_max_completion_tokens_model(model: str) -> bool:
"""Match every shape a GPT-5 model name might arrive in. Includes:
- bare: "gpt-5", "gpt-5.5", "gpt-5.4-mini"
- api-suffixed: "gpt-5.5-api" (desktop's pinned-api naming)
- 9router-prefixed: "openai/gpt-5.5" (post-translation name)
- codex-routed: "cx/gpt-5.3-codex" (CLI subscription)
Anything WITHOUT "gpt-5" in the (lowercased) string is rejected.
"""
"""Match every shape a GPT-5 name might arrive in (bare, api-suffixed, openai/-prefixed, cx/-routed)."""
m = (model or "").strip().lower()
if not m:
return False
# Strip common routing prefixes so we can match the bare model body.
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
if m.startswith(prefix):
m = m[len(prefix):]
@@ -136,12 +92,7 @@ def _is_openai_max_completion_tokens_model(model: str) -> bool:
def _scrub_request_for_openai_gpt5(body: bytes) -> bytes:
"""Rename `max_tokens` → `max_completion_tokens` for GPT-5 models.
Bytes-in/out, never raises. No-op if the body isn't JSON or doesn't
contain `max_tokens`. Drops the legacy field if BOTH are present so
the API doesn't reject for "both fields specified".
"""
"""Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises."""
if not body:
return body
try:
@@ -179,8 +130,7 @@ def _scrub_request_for_gemini(body: bytes) -> bytes:
return json.dumps(parsed).encode("utf-8")
# Headers we strip before forwarding — these change hop-by-hop or we
# replace them with upstream-specific auth.
# Hop-by-hop headers or auth we replace with the upstream-specific value.
_HOP_HEADERS = {
"host",
"content-length",
@@ -206,9 +156,7 @@ def _is_gemini_model(model: str) -> bool:
m = (model or "").strip().lower()
if m.startswith(_GEMINI_MODEL_PREFIXES):
return True
# Bare-name match: "gemini-3-flash-api", "gemini-3.1-pro-api", etc.
# Excludes anthropic-routed gemini models (those carry "/" or other
# routing prefixes via the registry).
# Bare-name match for own-key Gemini; excludes anthropic-routed gemini (those carry "/").
if "/" in m:
return False
return any(m.startswith(p) for p in _GEMINI_BARE_MODEL_PATTERNS)
@@ -220,15 +168,12 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
s = load_settings()
if _is_claude_model(model):
# Prefer Pro cloud proxy when configured.
if getattr(s, "connection_mode", "own_key") == "openswarm-pro":
bearer = getattr(s, "openswarm_bearer_token", "") or ""
proxy = (getattr(s, "openswarm_proxy_url", "") or "https://api.openswarm.com").rstrip("/")
if bearer and proxy:
return (proxy, {"Authorization": f"Bearer {bearer}"})
# Fall through — let 9Router handle it (maybe user has a real Claude sub).
# Default: 9Router for everything else (cx/, gc/, gh/, apikey-routed models).
return ("http://127.0.0.1:20128", {"x-api-key": "9router"})
@@ -243,7 +188,7 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
include_in_schema=False,
)
async def _healthcheck():
"""CLI healthchecks the proxy root return 200 so it doesn't 404."""
"""CLI healthchecks the proxy root; return 200 so it doesn't 404."""
return {"ok": True}
@@ -272,13 +217,7 @@ async def proxy(rest: str, request: Request):
for k, v in request.headers.items():
if k.lower() in _HOP_HEADERS:
continue
# The CLI we spawn carries our per-install auth token via
# `x-api-key` (we set `ANTHROPIC_API_KEY=<our_token>` on the
# spawn env, and the CLI forwards that value as x-api-key). We
# must NOT forward that header to the real upstream — it would
# leak our local token to api.openswarm.com / 9Router, AND it
# would shadow the real upstream auth (bearer or `9router`
# literal) that `_pick_upstream` wants to set. Strip it here.
# CLI carries our install token as x-api-key; never forward (leak + shadows real upstream auth).
if k.lower() == "x-api-key":
continue
forward_headers[k] = v
+43 -43
View File
@@ -73,7 +73,7 @@ def _hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str
"""Build a stable hash key for a tool call, including its result.
Including the result hash means that legitimate progress (same input,
different output e.g. BrowserScroll on a long feed) does NOT count
different output; e.g. BrowserScroll on a long feed) does NOT count
as a loop. Only same-input + same-output is treated as stuck.
"""
try:
@@ -108,7 +108,7 @@ def _detect_loop(
_LOOP_WARNING_TEXT = (
"LOOP DETECTED: You have called this tool with these exact parameters and "
"gotten the same result {count} times in a row. STOP retrying this approach "
" it is not working. Try a fundamentally different strategy: "
", it is not working. Try a fundamentally different strategy: "
"(1) check the page state with BrowserScreenshot or BrowserGetText, "
"(2) try a different selector or a different tool, "
"(3) use BrowserPressKey for keyboard shortcuts if the site supports them, "
@@ -121,7 +121,7 @@ def _validate_message_pairing(messages: list[dict]) -> bool:
message in the same list. Returns False if there's an orphan, which means
the cached history would 400 if sent to the API.
This is the last line of defense against cache corruption if it ever
This is the last line of defense against cache corruption; if it ever
returns False on a resume, we drop the cache and start fresh rather than
crash on the next API call.
"""
@@ -145,7 +145,7 @@ def _validate_message_pairing(messages: list[dict]) -> bool:
def _is_fresh_user_message(msg: dict) -> bool:
"""A 'fresh' user message starts a new turn string content or a list
"""A 'fresh' user message starts a new turn; string content or a list
that contains no tool_result blocks. These are the only safe cut points
because they don't reference any prior assistant tool_use blocks."""
if msg.get("role") != "user":
@@ -165,7 +165,7 @@ def _summarize_messages(messages: list[dict]) -> str:
Extracts the original user task, a count of tool calls by name with their
key parameters, the last few ReportProgress brain states, and the most
recent assistant text. No LLM call required this is purely structural
recent assistant text. No LLM call required; this is purely structural
extraction from the existing message history.
"""
if not messages:
@@ -256,7 +256,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
function avoids that by:
1. Walking forward to find a clean turn boundary (a fresh user-text
message that starts a new turn no tool_result content).
message that starts a new turn; no tool_result content).
2. Summarizing everything BEFORE that boundary into a single user-text
message and prepending it to the kept tail.
3. If no clean boundary exists at all, returning the original history
@@ -285,7 +285,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
# Second pass: if no cut point gets us under the cap (e.g. the current
# turn alone is bigger than max_messages), use the LATEST clean cut point
# available. The tail will still exceed the cap, but it's the smallest
# safe history we can produce and any compaction is better than none.
# safe history we can produce; and any compaction is better than none.
if cut_index is None:
for i in range(len(messages) - 1, 0, -1):
if _is_fresh_user_message(messages[i]):
@@ -293,7 +293,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
break
if cut_index is None:
# No clean cut anywhere in the history. Return original better to
# No clean cut anywhere in the history. Return original; better to
# exceed the cap than to corrupt the conversation.
return list(messages)
@@ -326,7 +326,7 @@ BROWSER_TOOLS_SCHEMA = [
"working_memory": {
"type": "string",
"description": (
"Short notes about what you've learned about this site so far "
"Short notes about what you've learned about this site so far; "
"selectors that work, keyboard shortcuts, layout quirks, what "
"you've tried that failed. Carry this forward across turns."
),
@@ -459,7 +459,7 @@ BROWSER_TOOLS_SCHEMA = [
"[2]<link \"Settings\">, etc. Use this BEFORE BrowserClickIndex. This is "
"the PREFERRED way to discover clickable elements on hostile sites "
"(Tinder, Instagram, TikTok) where CSS selectors fail because the page "
"uses unlabeled <div>s the accessibility tree sees roles and names "
"uses unlabeled <div>s; the accessibility tree sees roles and names "
"even when raw HTML doesn't expose them. Much more reliable than "
"BrowserGetElements (which uses CSS selectors)."
),
@@ -476,7 +476,7 @@ BROWSER_TOOLS_SCHEMA = [
"Uses native OS-level mouse events (event.isTrusted=true) so it works "
"on sites that filter out synthetic JS events. Always call "
"BrowserListInteractives first to get a fresh index list. If the click "
"returns 'index no longer valid', the page changed re-list and retry."
"returns 'index no longer valid', the page changed; re-list and retry."
),
"input_schema": {
"type": "object",
@@ -496,7 +496,7 @@ BROWSER_TOOLS_SCHEMA = [
"is executed in order, with the URL captured before/after each one. "
"If the URL changes mid-batch (the page navigated), the rest of the "
"batch is aborted and you get a partial result. Use this when you "
"have a known sequence typing then pressing Enter, swiping multiple "
"have a known sequence; typing then pressing Enter, swiping multiple "
"times, clicking through pagination. Max 5 actions per batch.\n\n"
"Sub-action types and their params:\n"
"- click_index: { index: int }\n"
@@ -537,7 +537,7 @@ BROWSER_TOOLS_SCHEMA = [
"description": (
"Press a keyboard key (or key combination) on the page using a real native "
"input event. Use this for keyboard shortcuts when JS-dispatched events get "
"ignored sites like Tinder, Slack, Notion, Gmail listen for trusted key "
"ignored; sites like Tinder, Slack, Notion, Gmail listen for trusted key "
"events. Examples: 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab', "
"'Space', single letters like 'a'. Prefer this over BrowserEvaluate with "
"dispatchEvent for keyboard shortcuts."
@@ -579,7 +579,7 @@ BROWSER_TOOLS_SCHEMA = [
"name": "RequestHumanIntervention",
"description": (
"Request the user's help when you encounter an obstacle you cannot solve "
"programmatically captchas, login prompts, cookie consent walls, "
"programmatically; captchas, login prompts, cookie consent walls, "
"two-factor authentication, or any blocking popup. The agent will pause "
"until the user resolves the issue and clicks Continue."
),
@@ -590,7 +590,7 @@ BROWSER_TOOLS_SCHEMA = [
"type": "string",
"description": (
"One short sentence describing the obstacle. Keep it under "
"15 words. Example: 'Login required please sign in to X/Twitter.'"
"15 words. Example: 'Login required; please sign in to X/Twitter.'"
),
},
"instruction": {
@@ -624,7 +624,7 @@ ACTION_MAP = {
SYSTEM_PROMPT = (
"You are a website-agnostic browser automation agent. You can operate on ANY "
"website the user is signed into social media, dating apps, email, productivity "
"website the user is signed into; social media, dating apps, email, productivity "
"tools, dashboards, ecommerce, anything. Assume the user has already logged in.\n\n"
"## Required output structure: ReportProgress before every action\n"
@@ -654,36 +654,36 @@ SYSTEM_PROMPT = (
"If this is a continuation of an earlier conversation on the same browser, the "
"messages above already contain everything you've tried, what worked, what failed, "
"and the page state. READ THAT HISTORY before acting. Do NOT take a fresh screenshot "
"or re-explore the DOM if you already know what's on screen just act. Only re-orient "
"or re-explore the DOM if you already know what's on screen; just act. Only re-orient "
"if the page has clearly changed (after navigation, after a multi-second wait, or if "
"your last action mutated the page in unexpected ways).\n\n"
"## Try multiple strategies, learn from failures\n"
"Sites vary wildly. When one approach fails, switch tactics don't retry the same "
"Sites vary wildly. When one approach fails, switch tactics; don't retry the same "
"thing. The escalation ladder, fastest to slowest:\n"
"1. **Keyboard shortcuts via BrowserPressKey** fastest and most reliable on sites "
"1. **Keyboard shortcuts via BrowserPressKey**; fastest and most reliable on sites "
"that support them (Tinder swipes, Gmail navigation, Slack message jump, etc.). "
"Always check if the site shows keyboard hints in the UI before falling back to clicks. "
"BrowserPressKey sends real native events that pass the `event.isTrusted` check, so "
"it works where dispatchEvent in BrowserEvaluate silently fails.\n"
"2. **Accessibility tree via BrowserListInteractives + BrowserClickIndex** the "
"2. **Accessibility tree via BrowserListInteractives + BrowserClickIndex**; the "
"accessibility tree sees roles and names that the raw DOM doesn't, even on sites "
"like Tinder, Instagram, and TikTok that use unlabeled <div>s with click handlers. "
"Call BrowserListInteractives to get a numbered list (`[1]<button \"Like\">`, "
"`[2]<link \"Settings\">`), then BrowserClickIndex with the number. The click uses "
"native OS-level mouse events so it works where DOM .click() doesn't. THIS IS YOUR "
"GO-TO STRATEGY for unlabeled or hostile sites try this BEFORE BrowserGetElements.\n"
"3. **Semantic CSS selectors** `button[aria-label='X']`, `[role='button']`, "
"GO-TO STRATEGY for unlabeled or hostile sites; try this BEFORE BrowserGetElements.\n"
"3. **Semantic CSS selectors**; `button[aria-label='X']`, `[role='button']`, "
"`a[href*='...']`. Try these via BrowserGetElements + BrowserClick when the site "
"actually has semantic HTML.\n"
"4. **Text-based JS query** when both of the above fail, use BrowserEvaluate to "
"4. **Text-based JS query**; when both of the above fail, use BrowserEvaluate to "
"find elements by visible text: `Array.from(document.querySelectorAll('*')).find(el => el.textContent.trim() === 'Like')`.\n"
"5. **Coordinate-based fallback** last resort: take a screenshot, identify the "
"5. **Coordinate-based fallback**; last resort: take a screenshot, identify the "
"button visually, then click by approximate coords.\n\n"
"## Batch known sequences with BrowserBatch\n"
"When you have a known sequence of actions typing then pressing Enter, "
"swiping multiple times, clicking through pagination emit them all in a "
"When you have a known sequence of actions; typing then pressing Enter, "
"swiping multiple times, clicking through pagination; emit them all in a "
"single BrowserBatch call instead of one tool per turn. The batch executes "
"sub-actions sequentially and aborts if the URL changes mid-batch (so you "
"won't operate on stale state). Max 5 sub-actions per batch.\n"
@@ -703,21 +703,21 @@ SYSTEM_PROMPT = (
"- Do NOT call the same failing tool twice with identical parameters. If selector "
"X failed, try a DIFFERENT selector or a DIFFERENT strategy.\n"
"- For repeated actions (swiping through profiles, going through inbox messages), "
"use BrowserPressKey if available it's an order of magnitude faster than DOM clicks.\n\n"
"use BrowserPressKey if available; it's an order of magnitude faster than DOM clicks.\n\n"
"## When you genuinely cannot proceed\n"
"Use RequestHumanIntervention for:\n"
"- Login walls (the user thinks they're logged in but the session expired)\n"
"- Captchas, 2FA prompts, age verification gates\n"
"- Anything genuinely ambiguous about user intent\n"
"Don't use it for normal tool failures try a different approach first.\n\n"
"Don't use it for normal tool failures; try a different approach first.\n\n"
"## Tool reference\n"
"- BrowserScreenshot: visual snapshot. Use sparingly, not after every action.\n"
"- BrowserGetText: returns up to 15000 chars of visible text. Useful for reading "
"content without an image.\n"
"- BrowserScroll: handles nested scroll containers (Notion, Gmail). Returns "
"atTop/atBottom stop looping when scroll delta is 0.\n"
"atTop/atBottom; stop looping when scroll delta is 0.\n"
"- BrowserGetElements: enumerate interactive elements with selectors.\n"
"- BrowserClick / BrowserType: standard DOM interaction.\n"
"- BrowserPressKey: native key events (preferred for shortcuts).\n"
@@ -730,7 +730,7 @@ SYSTEM_PROMPT = (
MAX_TURNS = 40
# Tools that count as "action tools" calling any of these in a turn requires
# Tools that count as "action tools"; calling any of these in a turn requires
# the model to also call ReportProgress in the same turn (after the first
# turn). Read-only tools and meta tools are exempt.
_ACTION_TOOLS_REQUIRING_REPORT = {
@@ -906,17 +906,17 @@ async def run_browser_agent(
# When the parent session is running on a non-Claude model (e.g. gpt-5.4),
# the browser agent inherits it and we route through 9Router's prefix.
# Tool-use fidelity for browser-specific tools (BrowserNavigate, click,
# type, etc.) through 9Router's claude→openai translator is UNVERIFIED
# type, etc.) through 9Router's claude→openai translator is UNVERIFIED ,
# if translation is poor, the user should manually switch this session
# back to Claude in the model picker.
if _find_builtin_model(model) is not None:
api_model = resolve_model_id_for_sdk(model, browser_settings)
else:
# Unknown model string fall back to whatever aux model is available
# Unknown model string; fall back to whatever aux model is available
try:
api_model, _ = await resolve_aux_model(browser_settings, preferred_tier="haiku")
except ValueError:
# Nothing connected at all surface a clear error so the caller
# Nothing connected at all; surface a clear error so the caller
# (parent agent) sees it in the tool result instead of crashing
# on a 400 from 9Router.
session.status = "error"
@@ -953,13 +953,13 @@ async def run_browser_agent(
# Resume prior conversation on this browser if we have one cached. This
# lets the sub-agent skip the "take a screenshot to figure out where I am"
# cycle every time the parent issues a new task. Defensively validate
# the cache if it's somehow corrupted (orphaned tool_use_ids), drop
# the cache; if it's somehow corrupted (orphaned tool_use_ids), drop
# it and start fresh rather than crash on the next API call.
prior_messages = _browser_history.get(browser_id) or []
if prior_messages and not _validate_message_pairing(prior_messages):
logger.warning(
f"[browser-agent {session_id}] cached history for {browser_id} has "
f"orphaned tool_use_ids dropping cache and starting fresh"
f"orphaned tool_use_ids; dropping cache and starting fresh"
)
_browser_history.pop(browser_id, None)
prior_messages = []
@@ -967,7 +967,7 @@ async def run_browser_agent(
action_log: list[dict] = []
final_screenshot: str | None = None
# Loop detection state sliding window of recent state-mutating tool calls
# Loop detection state; sliding window of recent state-mutating tool calls
recent_tool_calls: list[tuple[str, str, str]] = []
loop_trigger_count = 0
@@ -1094,7 +1094,7 @@ async def run_browser_agent(
logger.error(
f"[browser-agent {session_id}] hit "
f"{MAX_CONSECUTIVE_VIOLATIONS} consecutive ReportProgress "
f"violations aborting to prevent runaway loop"
f"violations; aborting to prevent runaway loop"
)
# Surface a user-visible error message so the frontend
# shows something coherent instead of just stopping.
@@ -1113,7 +1113,7 @@ async def run_browser_agent(
})
break
else:
# Reset on a clean turn only CONSECUTIVE violations
# Reset on a clean turn; only CONSECUTIVE violations
# count toward the limit. A single bad turn followed by
# a good one shouldn't kill the agent.
consecutive_violations = 0
@@ -1128,7 +1128,7 @@ async def run_browser_agent(
cancelled = True
break
# Handle ReportProgress no-op execution that just records the
# Handle ReportProgress; no-op execution that just records the
# model's brain state and streams it to the dashboard.
if tu.name == "ReportProgress":
eval_prev = tu.input.get("evaluation_previous", "")
@@ -1163,7 +1163,7 @@ async def run_browser_agent(
rejection_text = (
"REJECTED: You called an action tool without first calling "
"ReportProgress in the same turn. ReportProgress is REQUIRED "
"before every batch of action tools it's how you reflect "
"before every batch of action tools; it's how you reflect "
"on what just happened and articulate your next goal. Try "
"again: emit ReportProgress and your action tool(s) in the "
"same response."
@@ -1189,7 +1189,7 @@ async def run_browser_agent(
})
continue
# Handle RequestHumanIntervention pause and wait for user
# Handle RequestHumanIntervention; pause and wait for user
if tu.name == "RequestHumanIntervention":
problem = tu.input.get("problem", "")
instruction = tu.input.get("instruction", "")
@@ -1341,7 +1341,7 @@ async def run_browser_agent(
if loop_trigger_count >= _LOOP_HARD_CAP:
logger.warning(
f"[browser-agent {session_id}] hit loop hard cap "
f"({_LOOP_HARD_CAP}) force-exiting"
f"({_LOOP_HARD_CAP}); force-exiting"
)
break
@@ -1376,7 +1376,7 @@ async def run_browser_agent(
# Persist conversation history so the next BrowserAgent call on this
# browser can resume rather than re-orient. Trim to the most recent
# _MAX_HISTORY_MESSAGES turns to keep token usage bounded but
# _MAX_HISTORY_MESSAGES turns to keep token usage bounded; but
# never split a tool_use ↔ tool_result pair across the cut, or the
# next API request will 400.
_browser_history[browser_id] = _trim_history_by_turns(
@@ -1,10 +1,5 @@
#!/usr/bin/env python3
"""
Stdio MCP server that exposes BrowserAgent and BrowserAgents delegation tools.
Launched as a subprocess by the Claude Agent SDK. Proxies task delegation
to the OpenSwarm backend via HTTP, which runs browser sub-agents.
"""
"""Stdio MCP server exposing BrowserAgent/BrowserAgents delegation tools."""
import base64
import json
@@ -1,11 +1,5 @@
#!/usr/bin/env python3
"""
Stdio MCP server that exposes the InvokeAgent tool.
Launched as a subprocess by the Claude Agent SDK. Proxies invocation
requests to the OpenSwarm backend via HTTP, which forks the target
agent session and runs it with the new message.
"""
"""Stdio MCP server exposing the InvokeAgent tool; proxies to /api/invoke-agent/run."""
import json
import sys
@@ -113,7 +107,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
lines = [f"**Invoked Agent Result** (forked session: {forked_id})"]
if source_name:
lines[0] = f"**Invoked Agent Result** {source_name} (forked session: {forked_id})"
lines[0] = f"**Invoked Agent Result**; {source_name} (forked session: {forked_id})"
if cost > 0:
lines.append(f"*Cost: ${cost:.4f}*")
lines.append("")
+5 -23
View File
@@ -1,23 +1,5 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing the MCP activation gate.
Tools:
- MCPList: enumerate installed MCP servers (active + available).
- MCPSearch(query): rank servers by relevance to a free-form query.
- MCPActivate(server_name): activate a server for the rest of the session.
The activation gate is the dispatch-layer enforcement of the product invariant
"all MCP actions only via ToolSearch": the model can only reach an MCP server's
tools if the user has approved MCPActivate for that server, which appends to
session.active_mcps. _build_mcp_servers in agent_manager.py intersects connected
MCPs with that list before handing them to the SDK, so unactivated servers are
literally unreachable — the gate cannot be bypassed by ignoring prompt rules.
HITL: the model's invocation of MCPActivate goes through agent_manager's pre-
tool approval hook just like any other tool call — the user is prompted to
approve activation in the standard ApprovalBar UI. No separate HITL inside this
server.
"""
"""Stdio MCP server exposing the MCP activation gate (MCPList/MCPSearch/MCPActivate)."""
import json
import os
@@ -69,7 +51,7 @@ TOOLS = [
"description": (
"Request activation of an MCP server for this session. Triggers a "
"user approval prompt; on approve the server's tools become callable "
"next turn. Always confirm the server name via MCPList/MCPSearch first "
"next turn. Always confirm the server name via MCPList/MCPSearch first; "
"invalid names return alternatives instead of activating."
),
"inputSchema": {
@@ -81,7 +63,7 @@ TOOLS = [
},
"reason": {
"type": "string",
"description": "Why you need it shown to the user in the approval prompt.",
"description": "Why you need it; shown to the user in the approval prompt.",
},
},
"required": ["server_name"],
@@ -133,7 +115,7 @@ def format_servers(servers: list[dict], heading: str = "") -> str:
name = s.get("name", "")
desc = s.get("description") or f"{name} integration"
status = s.get("status", "available")
lines.append(f"- `{name}` [{status}] {desc}")
lines.append(f"- `{name}` [{status}]; {desc}")
return "\n".join(lines)
@@ -189,7 +171,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
"isError": True,
}
if result.get("status") == "already_active":
return {"content": [{"type": "text", "text": f"`{server_name}` is already active for this session its tools should be callable now."}]}
return {"content": [{"type": "text", "text": f"`{server_name}` is already active for this session; its tools should be callable now."}]}
if result.get("status") == "activated":
return {
"content": [{
+21 -86
View File
@@ -1,20 +1,4 @@
"""Pre-flight MCP suggestion classifier.
Runs before a new agent launches. Given the user's initial prompt, decides:
1. Is this prompt vague or information-gathering (is_vague) — used to
conditionally inject the discovery scaffolding into the system prompt.
2. Does it suggest a not-yet-connected MCP that would dramatically
improve the outcome — surfaced to the user as a one-click
"Connect X" modal before the agent runs.
Only the curated shortlist of MCPs we ship and have vetted is considered.
The full community MCP registry is NOT mined for suggestions — flaky/
unvetted entries would make the "magic" moment feel broken.
Provider-agnostic: calls whatever cheap-tier aux model the user has wired
via `resolve_aux_model` (Haiku / GPT-5.4-mini / Gemini-2.5-flash / etc.).
If no provider is connected, fails open (no suggestions, no scaffolding).
"""
"""Pre-flight classifier; decides is_vague (scaffolding inject) + suggests an MCP to connect. Fails open."""
from __future__ import annotations
@@ -32,56 +16,45 @@ from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool-agnostic discovery scaffolding — appended to the agent's system prompt
# only when preflight flags the prompt as vague/information-gathering.
# ---------------------------------------------------------------------------
# Tool-agnostic discovery scaffolding; appended only when preflight flags the prompt as vague/info-gathering.
DISCOVERY_SCAFFOLDING = (
"# Discovery before action\n"
"When a request is vague or could be grounded in user context, do not "
"guess generic defaults. First silently enumerate what would change the "
"output voice, tone, audience, prior context, recent precedent, facts "
"output; voice, tone, audience, prior context, recent precedent, facts "
"that only live in the user's data. Then look at your available tools and "
"pick the ones that could answer those unknowns. Read a few examples "
"(usually 310 is enough), summarize what you found into a few bullets, "
"(usually 3, 10 is enough), summarize what you found into a few bullets, "
"then act confidently.\n\n"
"Tool-selection hierarchy for information gathering:\n"
" 1. Direct local access (filesystem reads, code search, shell) "
" 1. Direct local access (filesystem reads, code search, shell); "
"cheapest and fastest.\n"
" 2. Connected services / MCP tools for user data that lives in a "
" 2. Connected services / MCP tools; for user data that lives in a "
"linked account (email, calendar, notes, tickets, etc.).\n"
" 3. Web search / fetch for public information that isn't in your "
" 3. Web search / fetch; for public information that isn't in your "
"training cutoff.\n"
" 4. Browser automation only when a real interactive session or "
" 4. Browser automation; only when a real interactive session or "
"login is required.\n"
" 5. Sub-agents only for parallelizable subtasks or to isolate heavy "
" 5. Sub-agents; only for parallelizable subtasks or to isolate heavy "
"context. Not for serial steps.\n\n"
"Asking the user is a fallback, not a first move. Never fabricate. If "
"no tool can ground a critical unknown, ask one concise question."
)
# ---------------------------------------------------------------------------
# Curated MCP shortlist. These `id` values MUST match the exact `name` field
# on ToolDefinition entries that OpenSwarm ships as defaults (see
# `backend/data/tools/*.json` — one file per tool, `name` is the canonical
# key used everywhere else in the app). Mismatches would cause the
# enabled/disabled filter to no-op and the frontend modal to render nothing.
#
# Keep in sync with the Custom Action Sets list in Settings → Tools.
# ---------------------------------------------------------------------------
# Curated shortlist; `id` MUST match ToolDefinition.name exactly or the enabled/dismissed filter no-ops and the modal renders nothing.
CuratedEntry = dict[str, Any]
CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "Google Workspace",
"title": "Google Workspace",
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides for reading/sending email, checking the user's schedule, and pulling context from their documents.",
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides; for reading/sending email, checking the user's schedule, and pulling context from their documents.",
},
{
"id": "Microsoft 365",
"title": "Microsoft 365",
"description": "Outlook email, Calendar, OneDrive, Teams, Excel, OneNote Microsoft-stack equivalent of Google Workspace.",
"description": "Outlook email, Calendar, OneDrive, Teams, Excel, OneNote; Microsoft-stack equivalent of Google Workspace.",
},
{
"id": "Slack",
@@ -101,7 +74,7 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "HubSpot",
"title": "HubSpot",
"description": "CRM contacts, deals, companies, tickets when the user's task involves their customer relationships.",
"description": "CRM contacts, deals, companies, tickets; when the user's task involves their customer relationships.",
},
{
"id": "Airtable",
@@ -111,58 +84,35 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "Reddit",
"title": "Reddit",
"description": "Browse subreddits, search posts, analyze users when the task involves public Reddit content.",
"description": "Browse subreddits, search posts, analyze users; when the task involves public Reddit content.",
},
{
"id": "YouTube",
"title": "YouTube",
"description": "Video transcripts, details, comments, channel stats, search when the task involves YouTube content.",
"description": "Video transcripts, details, comments, channel stats, search; when the task involves YouTube content.",
},
]
# ---------------------------------------------------------------------------
# Local skip filter — short-circuits the LLM call for obviously-local prompts
# where no MCP could add value. Saves ~200ms + ~$0.0001 per launch.
# ---------------------------------------------------------------------------
# Short-circuit for obviously-local prompts where no MCP helps. Saves ~200ms + ~$0.0001 per launch.
_PATH_LIKE = re.compile(r"^[./~]|/[\w\-]+/|\.[a-zA-Z]{1,5}\b")
_SHELL_PREFIX = re.compile(r"^\s*[\$!/]")
def _is_obviously_local(prompt: str) -> bool:
"""Heuristic: does this prompt obviously not need any MCP?
Returns True for:
- very short prompts (< 8 chars, likely greetings or acknowledgments)
- shell-command-ish prompts ("! ls", "$ git status", "/clear")
- prompts that are essentially a single file path reference
On True we skip preflight entirely; the agent launches with no
scaffolding and no suggestion modal.
"""
"""True for prompts that obviously can't benefit from MCP (very short, shell-ish, single path)."""
s = prompt.strip()
if len(s) < 8:
return True
if _SHELL_PREFIX.match(s):
return True
# Single-token path-ish prompt (e.g. "./src/foo.ts")
if " " not in s and _PATH_LIKE.search(s):
return True
return False
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
async def run_preflight(prompt: str, timeout_s: float = 2.0) -> dict:
"""Classify the user's prompt and return suggestions + vagueness flag.
Always returns a dict of shape:
{"is_vague": bool, "suggestions": [Suggestion, ...]}
Never raises: any failure (no provider, aux model timeout, bad JSON)
fails open — returns is_vague=False and empty suggestions.
"""
"""Classify the prompt and return {is_vague, suggestions}; never raises."""
default: dict[str, Any] = {"is_vague": False, "suggestions": []}
if not prompt or not prompt.strip():
@@ -174,33 +124,20 @@ async def run_preflight(prompt: str, timeout_s: float = 2.0) -> dict:
try:
settings = load_settings()
available = _build_available_shortlist(settings)
if not available:
# Everything in the shortlist is either enabled, dismissed, or
# out-of-scope for this user. We still run the classifier (for
# is_vague) but with an empty candidate list — the model will
# only fill in is_vague and return no suggestions.
pass
result = await asyncio.wait_for(
_call_classifier(settings, prompt, available),
timeout=timeout_s,
)
# Re-validate suggestion ids against the curated shortlist so a
# hallucinated id can't reach the frontend.
# Re-validate ids against the curated shortlist so hallucinations can't reach the frontend.
valid_ids = {e["id"] for e in CURATED_SHORTLIST}
result["suggestions"] = [
_decorate(s, available) for s in result.get("suggestions", [])
if isinstance(s, dict) and s.get("id") in valid_ids
]
# Drop anything that ended up with no matching available entry
# (e.g. already enabled by the user between preflight and now).
result["suggestions"] = [s for s in result["suggestions"] if s is not None]
result["is_vague"] = bool(result.get("is_vague"))
# Suppress suggestions on concrete prompts. False-positives here
# are worse than missed positives — interrupting a user who typed
# "refactor foo.ts" to ask about GitHub MCP would feel broken.
# Vague/info-gathering prompts are where suggestions help; concrete
# tasks should just launch.
# Suppress on concrete prompts; false-positives feel broken (interrupting "refactor foo.ts" to suggest GitHub MCP).
if not result["is_vague"]:
result["suggestions"] = []
return result
@@ -246,7 +183,7 @@ async def _call_classifier(settings, prompt: str, available: list[CuratedEntry])
client = get_anthropic_client_for_model(settings, aux_model)
catalog_lines = "\n".join(
f"- id: {e['id']} | {e['title']} {e['description']}"
f"- id: {e['id']} | {e['title']}; {e['description']}"
for e in available
) or "- (no candidate services available for this user)"
@@ -282,7 +219,6 @@ async def _call_classifier(settings, prompt: str, available: list[CuratedEntry])
messages=[{"role": "user", "content": user_turn}],
)
# Extract text content. Handle both string and content-block shapes.
text = ""
if isinstance(resp.content, list):
for block in resp.content:
@@ -293,7 +229,6 @@ async def _call_classifier(settings, prompt: str, available: list[CuratedEntry])
text = str(resp.content)
text = text.strip()
# Strip any accidental code fences
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```\s*$", "", text)
+19 -86
View File
@@ -11,7 +11,7 @@ class AgentConfig(BaseModel):
system_prompt: Optional[str] = None
allowed_tools: list[str] = Field(default_factory=lambda: ["Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion"])
max_turns: Optional[int] = None
target_directory: Optional[str] = None # if None, uses repo root
target_directory: Optional[str] = None
dashboard_id: Optional[str] = None
class ApprovalRequest(BaseModel):
@@ -39,26 +39,15 @@ class Message(BaseModel):
forced_tools: Optional[list[str]] = None
images: Optional[list[dict]] = None
hidden: bool = False
# Optional client-generated id used by the frontend to reconcile an
# optimistic message bubble (rendered synchronously on send) with the
# server-confirmed echo. Plumbed through send_message and round-tripped
# back via the agent:message WS event so the frontend can dedupe.
# Frontend-generated id for optimistic-bubble dedup against the server echo.
client_message_id: Optional[str] = None
# Wall-clock duration in milliseconds spent producing this message's
# content. For thinking blocks: time from content_block_start →
# content_block_stop. Lets the persisted ThinkingBubble show
# "Thought for Ns" on reload instead of falling back to the static
# "Thoughts" label. Optional for back-compat with messages saved
# before this field existed.
# Wall-clock ms producing this message's content; for thinking, content_block_start -> stop. Lets reloaded bubbles show "Thought for Ns".
elapsed_ms: Optional[int] = None
# Approximate output tokens for this message's content. For thinking
# blocks we use the same char/3.6 heuristic the live UI uses so the
# number frozen on the persisted bubble matches what the user saw
# rising during the stream. Pure display, not billing.
# Approx output tokens; thinking uses char/3.6 to match the live UI's count. Display only.
tokens: Optional[int] = None
# tool_count drives the "3 tools used" segment on the thinking pill.
# Drives the "N tools used" segment on the thinking pill.
tool_count: Optional[int] = None
# combined input + output + children tokens for the turn (overloaded name).
# Combined input + output + children tokens for the turn (overloaded name).
input_tokens: Optional[int] = None
class MessageBranch(BaseModel):
@@ -85,40 +74,22 @@ class AgentSession(BaseModel):
allowed_tools: list[str] = Field(default_factory=list)
max_turns: Optional[int] = None
cwd: Optional[str] = None
# Origin remote and branch resolved at session start. Persisted so a
# resumed session reattaches to the same project even if the user has
# since `cd`'d elsewhere; also surfaced in the session list UI so the
# user can tell two sessions apart by repo.
# Resolved at session start so resume reattaches to the same repo even after the user cd's elsewhere.
repo_url: Optional[str] = None
branch: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.now)
closed_at: Optional[datetime] = None
# Wall-clock of the first stream event from the agent SDK. Set once
# at the start of the first turn so resumed sessions can show "first
# response was at HH:MM" in the session list without rescanning the
# message log.
# Wall-clock of the first stream event so resumed sessions can show "first response at HH:MM" without rescan.
first_response_at: Optional[datetime] = None
# Operational log of HITL approval decisions, one entry per request:
# {tool, behavior, decision_ms}. Persisted alongside the session so a
# reload restores the full approval timeline (which calls were
# approved, denied, and how long each took).
# HITL approval log: {tool, behavior, decision_ms} per entry.
approval_decisions: list[dict] = Field(default_factory=list)
cost_usd: float = 0.0
tokens: dict[str, int] = Field(default_factory=lambda: {"input": 0, "output": 0})
# Total wall-clock ms the agent spent in `status="running"`. Accumulates
# across turns; persists across resume. Used by the session-close
# report so we can report "agent active time" alongside total session
# duration. Off by default so legacy sessions deserialize cleanly.
# Total ms in status="running", accumulated across turns/resume; powers session-close "agent active time".
agent_active_ms: int = 0
# Accumulated wall-clock ms spent on each model. Updated when the
# active model changes (model switch) or on close. Surfaced in the
# session header so the user can see "Sonnet: 45s · Haiku: 12s"
# without scanning turns by hand.
# Per-model wall-clock ms; updated on model switch or close.
time_per_model: dict[str, int] = Field(default_factory=dict)
# Per-tool latency rollup: { tool_name: { count, total_ms, max_ms } }.
# Populated as tools complete. Surfaced in the session "tools used"
# row so the user can see which tool calls were slow without
# opening every turn.
# Per-tool latency: { tool_name: { count, total_ms, max_ms } }.
tool_latencies: dict[str, dict] = Field(default_factory=dict)
browser_domains: list[str] = Field(default_factory=list)
messages: list[Message] = Field(default_factory=list)
@@ -130,58 +101,20 @@ class AgentSession(BaseModel):
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
needs_fork: bool = False
# Stronger than needs_fork: when True, the next turn drops `resume=`
# entirely and replays history into a brand-new sdk_session_id. This
# is the only way to make the bundled CLI re-read mcp_servers from
# the rebuilt options dict — `fork_session=True` only forks the
# conversation tree, it inherits the original transport's MCP server
# set. Set after MCPActivate when prior turns exist so the newly
# activated server's tools actually reach the model.
# Stronger than needs_fork: drop resume= and replay history into a fresh sdk_session_id; fork_session alone won't re-read mcp_servers.
needs_fresh_session: bool = False
# Set when MCPActivate (or analogous activation) wants the agent to
# auto-continue immediately after the current turn ends — without
# requiring the user to type another message. The agent loop reads
# this at the end of `_run_agent_loop`; if set, it clears it and
# dispatches a new hidden turn with `pending_continuation_prompt` as
# the prompt. Race-free vs. the original asyncio-task approach.
# Auto-continue: agent loop dispatches a hidden turn at end-of-loop using pending_continuation_prompt. Race-free vs background tasks.
pending_continuation: bool = False
pending_continuation_prompt: Optional[str] = None
# Sanitized server names (matching tools_lib._sanitize_server_name) of MCP
# servers the model has explicitly activated this session via the
# MCPActivate meta-tool. Empty by default — the gate in
# _build_mcp_servers intersects connected MCPs with this list, so no
# MCP tool is callable until the model searches for and activates a
# server. The product invariant is that this is non-bypassable: the
# filter lives at the dispatch layer (mcp_servers passed to the SDK),
# not the prompt layer.
# Sanitized server names model has explicitly activated this session; _build_mcp_servers intersects connected MCPs with this. Non-bypassable; dispatch-layer gate.
active_mcps: list[str] = Field(default_factory=list)
# Estimated framework preamble tokens (preset + tool defs + MCP descs +
# composed prompt). Subtracted from displayed input for honest "this turn"
# numbers. Heuristic; clamped >= 0.
# Heuristic preamble tokens (preset + tool defs + MCP descs + composed prompt); subtracted from displayed input.
framework_overhead_tokens: int = 0
# Compaction state. compact_threshold_pct is the live ctx_used ratio
# that triggers _maybe_compact at the next turn boundary — turn-based
# thresholds break under uneven workloads (one big Bash dump fills
# context fast; 30 chitchat turns barely move it). 0.65 = 130K of the
# 200K standard tier. compacted_through_msg_id is the last message id
# covered by the most recent summary so we don't re-summarize on
# every turn.
# Live ctx_used ratio triggering _maybe_compact at the next turn boundary; turn-based thresholds break under uneven workloads. 0.65 = 130K of 200K.
compact_threshold_pct: float = 0.65
compacted_through_msg_id: Optional[str] = None
# Pre-send hard guard. Fires later than the compaction threshold —
# 0.90 of 200K = 180K — to give the auto-compact path a chance to
# bring the request back under the ceiling. If still over after
# compaction, LRU-trim the oldest active_mcps. Past this we surface
# the friendly context-overflow card instead of letting a 429 hit.
# Hard pre-send guard at 0.90 (= 180K); past compaction we LRU-trim active_mcps, then surface the overflow card.
context_soft_cap_pct: float = 0.90
context_window: int = 200_000
# How much the model should "think" before answering. Provider-agnostic
# value that gets translated per-API in agent_manager:
# off — no thinking
# low — minimal thinking (fastest)
# medium — balanced
# high — extensive thinking (slowest, smartest)
# auto — let the model / provider default decide (recommended)
# Only applies to models flagged with reasoning: True in the registry.
# Existing sessions without this field will default to "auto".
# Provider-agnostic thinking level (off/low/medium/high/auto), translated per-API in agent_manager; only affects reasoning-flagged models.
thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
+4 -37
View File
@@ -1,28 +1,4 @@
"""Tiny OpenAI-API pass-through with `max_tokens` → `max_completion_tokens`
rename for GPT-5.x models.
Why this exists
---------------
OpenAI's GPT-5 family (gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, etc.)
rejects the legacy `max_tokens` parameter with HTTP 400:
"Unsupported parameter: 'max_tokens' is not supported with this model.
Use 'max_completion_tokens'."
Anthropic's CLI emits requests in Anthropic format (which uses `max_tokens`),
9Router 0.3.60 translates Anthropic→OpenAI and preserves `max_tokens`
(it doesn't know about the GPT-5 change). We can't bump 9Router because
0.3.60 is pinned to fix a separate WebSearch regression in the 0.3.x
range (see backend/apps/nine_router.py:27-36).
So we slot a thin proxy between 9Router and api.openai.com. The CLI is
unaware: it sees its OPENAI_BASE_URL pointing at this local passthrough,
not OpenAI. We rename the field for GPT-5 models and forward unchanged
otherwise. Streaming + non-streaming both work because we proxy bytes.
Mounted at `/api/openai-passthrough` and consumed by setting
OPENAI_BASE_URL to `http://127.0.0.1:<port>/api/openai-passthrough/v1`
in the CLI's spawn env (see agent_manager.py).
"""
"""Tiny OpenAI passthrough renaming max_tokens to max_completion_tokens for GPT-5; 9Router 0.3.60 is pinned and doesn't know the change."""
import json
import logging
@@ -45,8 +21,7 @@ async def openai_passthrough_lifespan():
openai_passthrough = SubApp("openai-passthrough", openai_passthrough_lifespan)
# Models that REQUIRE max_completion_tokens. Mirrors anthropic_proxy.py's
# matcher but lives here so this module doesn't depend on that one.
# Mirrors anthropic_proxy.py's GPT-5 matcher; duplicated to avoid the cross-module dep.
_GPT5_PREFIXES = ("gpt-5",)
_OPENAI_UPSTREAM = "https://api.openai.com/v1"
_HOP_HEADERS = {
@@ -60,7 +35,6 @@ def _is_gpt5(model: str) -> bool:
m = (model or "").strip().lower()
if not m:
return False
# Strip routing prefixes 9Router may have added.
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
if m.startswith(prefix):
m = m[len(prefix):]
@@ -69,12 +43,7 @@ def _is_gpt5(model: str) -> bool:
def _scrub_max_tokens(body: bytes) -> bytes:
"""Rename max_tokens max_completion_tokens for GPT-5 models.
Bytes-in/out, never raises. No-op if body isn't JSON, model isn't GPT-5,
or max_tokens isn't present. If both fields are present (unlikely),
drops the legacy field so OpenAI doesn't 400 on the conflict.
"""
"""Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises."""
if not body:
return body
try:
@@ -113,9 +82,7 @@ async def passthrough(rest: str, request: Request):
if request.url.query:
upstream_url = f"{upstream_url}?{request.url.query}"
# Stream upstream response body straight back to the caller. httpx's
# streaming context handles Server-Sent Events the CLI uses for chat
# completions without buffering the full response in memory.
# Stream upstream body back; httpx handles SSE without buffering the full response.
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=30.0))
try:
upstream_req = client.build_request(
+17 -17
View File
@@ -251,7 +251,7 @@ async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
continue
if isinstance(out_mods, list) and out_mods and "text" not in out_mods:
continue
# Tools required agent loop doesn't work without function calling.
# Tools required; agent loop doesn't work without function calling.
params = m.get("supported_parameters") or []
if not isinstance(params, list) or "tools" not in params:
continue
@@ -329,7 +329,7 @@ _CUSTOM_VALUE_PREFIX = "custom/"
def _custom_provider_slug_for_lookup(name: str) -> str:
"""Mirror nine_router._custom_provider_slug duplicated here to avoid
"""Mirror nine_router._custom_provider_slug; duplicated here to avoid
importing from nine_router (circular: nine_router imports from settings)."""
import re
s = re.sub(r"[^a-zA-Z0-9-]+", "-", (name or "").strip().lower()).strip("-")
@@ -355,7 +355,7 @@ def _find_builtin_model(short_name: str) -> dict | None:
"""Look up a model entry by its short `value`.
OpenRouter entries (prefixed `or:<vendor>/<model>`) and custom-provider
entries (prefixed `custom/<slug>/<model_id>`) aren't in BUILTIN_MODELS
entries (prefixed `custom/<slug>/<model_id>`) aren't in BUILTIN_MODELS ,
they're synthesised on demand so the rest of the routing code can treat
them like BUILTIN_MODELS entries."""
for models in BUILTIN_MODELS.values():
@@ -520,7 +520,7 @@ async def resolve_aux_model(
return ("cx/gpt-5.4-mini", base_url)
if "gemini-cli" in connected:
return ("gc/gemini-3.1-flash-lite-preview", base_url)
# OR is metered, hence last saves OR-only users from "Untitled session" hell.
# OR is metered, hence last; saves OR-only users from "Untitled session" hell.
if "openrouter" in connected:
return (or_aux, base_url)
@@ -538,7 +538,7 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
if m["value"] == model:
return m.get("context_window", 128_000)
# Check custom providers picker values are `custom/<slug>/<bare_model>`;
# Check custom providers; picker values are `custom/<slug>/<bare_model>`;
# cp.models[].value stores the bare model id the user typed. Match the
# bare-model tail against any custom provider's models list.
if settings:
@@ -557,7 +557,7 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
# ---------------------------------------------------------------------------
# Curated model tiers Intelligence, Speed, Cost on a 1-5 scale
# Curated model tiers; Intelligence, Speed, Cost on a 1-5 scale
# ---------------------------------------------------------------------------
#
# Hand-tuned from public benchmarks + per-token pricing (knowledge cutoff
@@ -756,7 +756,7 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
import re as _re
out = output_cost_per_1m or 0.0
# Cost bucket same 5-tier cost ladder as before.
# Cost bucket; same 5-tier cost ladder as before.
if out < 0.5:
cb = 1
elif out < 2:
@@ -793,7 +793,7 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
elif param_b > 0:
size_tier = 1
else:
size_tier = 0 # unknown fall back to cost
size_tier = 0 # unknown; fall back to cost
# Intelligence is the max of cost bucket and parsed size tier.
# Cost is high-confidence for closed-source frontier; size is
@@ -802,7 +802,7 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
intel = max(cb, size_tier)
if reasoning and intel < 4:
# Reasoning is a strong intelligence signal but only for
# genuinely smaller models frontier closed-source already
# genuinely smaller models; frontier closed-source already
# caps at 5, so don't double-count there.
intel += 1
@@ -870,15 +870,15 @@ def compute_billing_kind(
settings,
) -> str:
"""Return one of:
'subscription' covered by an OAuth sub or Pro plan; hide cost row
'api_key' direct API-key path (Anthropic / OpenAI / Gemini)
'free' genuinely $0 per token (rate-limited OR :free tier)
'paid' per-token metering through OpenRouter; show pricing
'subscription'; covered by an OAuth sub or Pro plan; hide cost row
'api_key' ; direct API-key path (Anthropic / OpenAI / Gemini)
'free' ; genuinely $0 per token (rate-limited OR :free tier)
'paid' ; per-token metering through OpenRouter; show pricing
Why 'api_key' is split from 'paid': both meter per-token, but the user
is paying a different counterparty. Letting the picker filter chips
"API key" vs "Subscription" gives users a clear way to scope to their
billing relationship direct API key vs OAuth subscription instead
billing relationship; direct API key vs OAuth subscription; instead
of conflating them under a generic "paid" bucket.
Subscription paths:
@@ -913,7 +913,7 @@ def compute_billing_kind(
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# NOTE: `calculate_cost` is currently unused in the live path real
# NOTE: `calculate_cost` is currently unused in the live path; real
# cost numbers come from 9Router's usage stats. These entries are kept
# so the table matches BUILTIN_MODELS and can
# be used by any future native-loop path. Subscription-routed models
@@ -924,14 +924,14 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "opus-4-7"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI Codex subscription path, user pays nothing per token
# OpenAI; Codex subscription path, user pays nothing per token
("OpenAI", "gpt-5.5"): (0.0, 0.0),
("OpenAI", "gpt-5.4"): (0.0, 0.0),
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-high"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-xhigh"): (0.0, 0.0),
# Google Gemini CLI subscription path, user pays nothing per token
# Google; Gemini CLI subscription path, user pays nothing per token
("Google", "gemini-3.1-pro"): (0.0, 0.0),
("Google", "gemini-3.1-flash-lite"): (0.0, 0.0),
("Google", "gemini-3-pro"): (0.0, 0.0),
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing scheduled-workflow tools to the agent.
Why this exists: the agent should be able to schedule recurring work on
the user's behalf, but ALWAYS through the native scheduler (visible,
auditable, cost-capped) rather than `crontab`. Each tool is a thin
wrapper around /api/workflows/*. The descriptions are written to nudge
the agent toward AskUserQuestion-first behavior (confirm cadence with
the user before calling ScheduleWorkflow).
"""
import json
import sys
import os
import urllib.request
import urllib.error
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
BACKEND_BASE = f"http://127.0.0.1:{BACKEND_PORT}/api/workflows"
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
PRESETS = {
"daily_morning": {"enabled": True, "repeat_unit": "day", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []},
"weekdays_morning": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1, 2, 3, 4, 5]},
"weekly_monday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1]},
"weekly_friday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 17, "minute": 0, "on_days": [5]},
"monthly_first": {"enabled": True, "repeat_unit": "month", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []},
}
TOOLS = [
{
"name": "ScheduleWorkflow",
"description": (
"Create a recurring scheduled workflow for the user. Use this "
"ONLY after confirming cadence with the user via AskUserQuestion "
"(do not assume — the user must pick or accept the time). "
"The workflow runs the listed steps on the schedule and is "
"visible in the user's Workflows hub. Never use crontab, "
"launchctl, or schtasks to schedule recurring work; always use "
"this tool so the user can see, pause, edit, or delete it. "
"After creating, briefly confirm to the user what was scheduled."
),
"inputSchema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short workflow name shown in the hub and on the dashboard card."},
"steps": {
"type": "array",
"items": {"type": "string"},
"description": "Ordered list of instructions for the agent to execute on each fire. Each string is one step.",
},
"preset": {
"type": "string",
"enum": ["daily_morning", "weekdays_morning", "weekly_monday", "weekly_friday", "monthly_first", "custom"],
"description": "Cadence preset. Use 'custom' to specify your own hour/minute/days.",
},
"hour": {"type": "integer", "description": "Hour 0-23 in the user's local time. Required when preset='custom'."},
"minute": {"type": "integer", "description": "Minute 0/15/30/45. Required when preset='custom'."},
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"], "description": "Required when preset='custom'."},
"on_days": {
"type": "array",
"items": {"type": "integer"},
"description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.",
},
"source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."},
},
"required": ["title", "steps", "preset"],
},
},
{
"name": "ListScheduledWorkflows",
"description": "List the user's scheduled workflows. Use this to find a workflow the user is referring to before editing or deleting it.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "UpdateScheduledWorkflow",
"description": "Modify an existing scheduled workflow. Only pass the fields you want to change. Always confirm with the user via AskUserQuestion before making changes that meaningfully alter behavior (cadence, steps, permissions).",
"inputSchema": {
"type": "object",
"properties": {
"workflow_id": {"type": "string"},
"title": {"type": "string"},
"steps": {"type": "array", "items": {"type": "string"}},
"schedule_enabled": {"type": "boolean", "description": "Quick on/off without changing other schedule fields."},
"hour": {"type": "integer"},
"minute": {"type": "integer"},
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"]},
"on_days": {"type": "array", "items": {"type": "integer"}},
},
"required": ["workflow_id"],
},
},
{
"name": "DeleteScheduledWorkflow",
"description": "Permanently delete a scheduled workflow. Cannot be undone. ALWAYS confirm via AskUserQuestion before calling this — the user should pick from a list, not have you guess.",
"inputSchema": {
"type": "object",
"properties": {"workflow_id": {"type": "string"}},
"required": ["workflow_id"],
},
},
{
"name": "PauseAllWorkflows",
"description": "Globally pause every scheduled workflow. In-flight runs finish; future runs are blocked until resumed. Use when the user wants a temporary stop (vacation, debugging) without deleting workflows.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "ResumeAllWorkflows",
"description": "Resume scheduled workflows after a previous PauseAllWorkflows.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "RunWorkflowNow",
"description": "Trigger an immediate one-off run of a scheduled workflow. The schedule continues to fire on its normal cadence in addition.",
"inputSchema": {
"type": "object",
"properties": {"workflow_id": {"type": "string"}},
"required": ["workflow_id"],
},
},
]
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(method: str, path: str, body=None) -> dict:
url = BACKEND_BASE + path
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"}
if BACKEND_AUTH:
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode() or "null") or {}
except urllib.error.HTTPError as e:
body_err = e.read().decode() if e.fp else str(e)
return {"_error": f"HTTP {e.code}: {body_err}"}
except Exception as e:
return {"_error": str(e)}
def _build_schedule_from_preset(preset: str, args: dict) -> dict:
base = {"timezone": "local", "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
if preset == "custom":
return {
**base,
"enabled": True,
"repeat_unit": args.get("repeat_unit", "day"),
"repeat_every": 1,
"hour": int(args.get("hour", 9)),
"minute": int(args.get("minute", 0)),
"on_days": list(args.get("on_days") or []),
}
preset_def = PRESETS.get(preset)
if not preset_def:
return {}
return {**base, **preset_def, "repeat_every": 1}
def handle_schedule_workflow(args: dict) -> dict:
title = args.get("title") or "Scheduled workflow"
steps_in = args.get("steps") or []
preset = args.get("preset") or "daily_morning"
schedule = _build_schedule_from_preset(preset, args)
if not schedule:
return _err(f"Unknown preset: {preset}. Use one of: {list(PRESETS.keys()) + ['custom']}.")
body = {
"title": title,
"steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s],
"schedule": schedule,
"source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None,
}
r = _call("POST", "/create", body)
if "_error" in r:
return _err(r["_error"])
wid = r.get("id", "")
nxt = r.get("next_run_at") or "soon"
return _ok(f"Scheduled \"{title}\" ({preset}). Workflow id: {wid}. Next run: {nxt}. The user can view, pause, or edit it in the Workflows hub.")
def handle_list(_args: dict) -> dict:
r = _call("GET", "/list")
if "_error" in r:
return _err(r["_error"])
ws = r.get("workflows", [])
if not ws:
return _ok("No scheduled workflows yet.")
lines = ["Scheduled workflows:"]
for w in ws:
s = w.get("schedule") or {}
enabled = s.get("enabled")
unit = s.get("repeat_unit", "?")
hour = s.get("hour")
title = w.get("title", "(untitled)")
wid = w.get("id", "")
state = "ON" if enabled else "off"
lines.append(f" - {title} [{state}] {unit} at {hour:02d}:00 (id: {wid})")
return _ok("\n".join(lines))
def handle_update(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
cur = _call("GET", f"/{wid}")
if "_error" in cur:
return _err(cur["_error"])
sched = cur.get("schedule") or {}
patch: dict = {}
if "title" in args: patch["title"] = args["title"]
if "steps" in args:
patch["steps"] = [{"id": f"s{i+1}", "text": s} for i, s in enumerate(args["steps"] or []) if s]
sched_patch = dict(sched)
sched_dirty = False
if "schedule_enabled" in args:
sched_patch["enabled"] = bool(args["schedule_enabled"])
sched_dirty = True
for k in ("hour", "minute", "repeat_unit", "on_days"):
if k in args:
sched_patch[k] = args[k]
sched_dirty = True
if sched_dirty:
patch["schedule"] = sched_patch
if not patch:
return _ok(f"No changes requested for workflow {wid}.")
r = _call("PATCH", f"/{wid}", patch)
if "_error" in r:
return _err(r["_error"])
return _ok(f"Updated \"{r.get('title', wid)}\". Next run: {r.get('next_run_at') or 'paused/unscheduled'}.")
def handle_delete(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
r = _call("DELETE", f"/{wid}")
if "_error" in r:
return _err(r["_error"])
return _ok(f"Deleted workflow {wid}.")
def handle_pause_all(_args: dict) -> dict:
r = _call("POST", "/pause-all")
if "_error" in r:
return _err(r["_error"])
return _ok("All scheduled workflows are paused. In-flight runs will finish; future fires are blocked. Resume with ResumeAllWorkflows.")
def handle_resume_all(_args: dict) -> dict:
r = _call("POST", "/resume-all")
if "_error" in r:
return _err(r["_error"])
return _ok("Scheduled workflows resumed.")
def handle_run_now(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
r = _call("POST", f"/{wid}/run")
if "_error" in r:
return _err(r["_error"])
if r.get("status") == "skipped":
return _ok(f"Run was skipped: {r.get('error', 'unknown reason')}.")
return _ok(f"Run started (run id: {r.get('run_id', '')}). Output will appear in the workflow's History.")
def _ok(text: str) -> dict:
return {"content": [{"type": "text", "text": text}]}
def _err(text: str) -> dict:
return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True}
HANDLERS = {
"ScheduleWorkflow": handle_schedule_workflow,
"ListScheduledWorkflows": handle_list,
"UpdateScheduledWorkflow": handle_update,
"DeleteScheduledWorkflow": handle_delete,
"PauseAllWorkflows": handle_pause_all,
"ResumeAllWorkflows": handle_resume_all,
"RunWorkflowNow": handle_run_now,
}
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-schedule", "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", {})
handler = HANDLERS.get(tool_name)
if handler is None:
send_response(id_, _err(f"Unknown tool: {tool_name}"))
else:
send_response(id_, handler(arguments))
elif method == "ping":
send_response(id_, {})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
+10 -88
View File
@@ -1,49 +1,4 @@
"""Per-session WS event sequencing, ring buffer, and terminal-event persistence.
Why this exists
---------------
WS sockets die for a thousand reasons that have nothing to do with the
agent task: laptop sleep, captive portals, NAT idle timeout, VPN
renegotiation. Without this module, a transient drop is fatal —
mid-stream events are lost forever and the UI can't tell whether the
run finished or merely went quiet.
Contract
--------
Every WS event for a session goes through `stamp(...)`, which is an
async context manager that:
1. Acquires the per-session lock.
2. Bumps a monotonic `seq` integer.
3. Appends the JSON payload to a bounded ring buffer.
4. Yields (seq, payload_str) to the caller.
5. Holds the lock until the caller exits the `async with` — meaning
the caller's `ws.send_text(...)` happens *under the same lock*,
guaranteeing wire order == seq order even when many coroutines
broadcast concurrently.
Without (5), two coroutines can each get a unique seq under separate
lock acquisitions, yet the higher-seq event can reach the wire first
because asyncio scheduled its `send_text` earlier. That corrupts both
wire order and the ring buffer on resume.
Resume protocol
---------------
On reconnect, the client sends `client:resume {connection_uuid,
last_seq}`. The server:
- Returns ring-buffer events with `seq > last_seq` if available.
- Returns `agent:gap_detected` if `last_seq` is older than the
oldest buffered seq — the client falls back to a REST refresh.
- Returns the persisted terminal event (if any) when the session
is no longer in memory at all (e.g. after a process restart).
Persistence
-----------
Terminal events (status: completed/stopped/error) are written
atomically to disk so a client that comes back hours later — long
after the in-memory ring buffer has been GC'd — still sees the right
outcome instead of a spinner that never resolves. Persistence is
opportunistic: an I/O error never blocks the broadcast path.
"""
"""Per-session WS event sequencing, ring buffer, and terminal-event persistence for resilient reconnects."""
from __future__ import annotations
@@ -57,9 +12,7 @@ from typing import AsyncIterator, Optional
logger = logging.getLogger(__name__)
# Ring buffer size per session. ~500 events comfortably covers a 30s
# transient drop even in the busiest streams (thinking deltas at
# ~20Hz). Memory is bounded: ~50KB per active session.
# 500 events covers a 30s drop even at ~20Hz thinking deltas (~50KB/session).
BUFFER_LIMIT = 500
TERMINAL_STATUSES = {"completed", "stopped", "error"}
@@ -73,8 +26,7 @@ class _SessionSeqLog:
def __init__(self) -> None:
self.lock: asyncio.Lock = asyncio.Lock()
self.seq: int = 0
# Each entry: (seq, json_payload_str). Pre-serialized so a
# replay doesn't redo json.dumps for every reconnect.
# (seq, json_payload_str): pre-serialized so replays don't redo json.dumps per reconnect.
self.buffer: deque[tuple[int, str]] = deque(maxlen=BUFFER_LIMIT)
@@ -83,8 +35,7 @@ class SeqLogStore:
def __init__(self, persist_dir: Optional[str] = None) -> None:
self._per_session: dict[str, _SessionSeqLog] = {}
# Coarse lock guarding only the dict's setdefault path. Held
# for nanoseconds; never crosses an `await` past the `_get`.
# Coarse lock guards only the setdefault path; never crosses an await.
self._dict_lock = asyncio.Lock()
self._persist_dir = persist_dir
if persist_dir:
@@ -111,13 +62,7 @@ class SeqLogStore:
async def stamp(
self, session_id: str, event: str, data: dict
) -> AsyncIterator[tuple[int, str]]:
"""Atomically assign a seq, buffer it, and yield (seq, payload).
Caller is expected to perform the actual `send_text` *inside*
the `async with` block. The per-session lock is held for the
entire body, so wire order is guaranteed equal to seq order
no matter how many tasks broadcast concurrently.
"""
"""Atomically assign seq, buffer, and yield (seq, payload); caller's send must happen inside the with-block."""
log = await self._get_or_create(session_id)
async with log.lock:
log.seq += 1
@@ -135,23 +80,11 @@ class SeqLogStore:
def replay(
self, session_id: str, last_seq: int
) -> tuple[Optional[int], Optional[int], list[str]]:
"""Return (oldest_buffered_seq, newest_buffered_seq, events).
Caller decides what to do with the result:
- `events` empty AND newest_buffered_seq is None: no buffer
for this session in memory. Fall back to persisted
terminal event.
- `last_seq` < `oldest_buffered_seq`: there's a gap. Send
`agent:gap_detected`; the client REST-refreshes.
- Otherwise `events` are the missed payloads in seq order.
"""
"""Return (oldest_buffered_seq, newest_buffered_seq, events)."""
log = self._peek(session_id)
if log is None:
return (None, None, [])
# Snapshot the deque under the lock-free fast path. asyncio is
# single-threaded so a list() of a deque mutated by append is
# safe; eviction (via maxlen) is also a single-step op. We
# don't need to hold the per-session lock for a read.
# asyncio is single-threaded; deque list() is safe vs concurrent append/eviction. No lock needed for read.
snapshot = list(log.buffer)
if not snapshot:
return (None, log.seq, [])
@@ -165,23 +98,17 @@ class SeqLogStore:
log = self._peek(session_id)
return log.seq if log else 0
# ----- Terminal-event persistence -----
def _terminal_path(self, session_id: str) -> Optional[str]:
if not self._persist_dir:
return None
# session ids are uuid4 hex in this codebase, but sanitize
# against path traversal anyway.
# Session ids are uuid4 hex; sanitize anyway against path traversal.
safe = "".join(c for c in session_id if c.isalnum() or c in ("-", "_"))
if not safe:
return None
return os.path.join(self._persist_dir, f"{safe}.json")
def persist_terminal(self, session_id: str, payload_str: str) -> None:
"""Atomic write of a terminal event for post-restart clients.
Best-effort: an I/O failure must never block the broadcast.
"""
"""Atomic write of a terminal event for post-restart clients; best-effort, never blocks broadcast."""
path = self._terminal_path(session_id)
if not path:
return
@@ -206,11 +133,7 @@ class SeqLogStore:
return None
def clear(self, session_id: str) -> None:
"""Drop in-memory log + persisted terminal event.
Use on full session deletion. Closed-but-retained sessions
keep their terminal file so late reconnects still resolve.
"""
"""Drop in-memory log and persisted terminal; for full deletion only, closed-but-retained sessions keep it."""
self._per_session.pop(session_id, None)
path = self._terminal_path(session_id)
if path and os.path.exists(path):
@@ -228,5 +151,4 @@ def _default_persist_dir() -> Optional[str]:
return None
# Process-wide singleton wired to the agents data dir.
seq_log = SeqLogStore(persist_dir=_default_persist_dir())
+6 -27
View File
@@ -10,8 +10,8 @@ import httpx
from backend.apps.agents.tools.base import BaseTool, ToolContext
_HTTP_TIMEOUT = 30 # seconds
_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs
_HTTP_TIMEOUT = 30
_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs.
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
@@ -25,24 +25,15 @@ def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str:
def _strip_html(raw_html: str) -> str:
"""Naive but effective HTML plain-text conversion."""
# Remove script/style blocks
"""Naive but effective HTML to plain-text conversion."""
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
# Remove HTML tags
text = re.sub(r"<[^>]+>", " ", text)
# Decode HTML entities
text = html.unescape(text)
# Collapse whitespace
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ───────────────────────────────────────────────────────────────────────────
# WebSearchTool
# ───────────────────────────────────────────────────────────────────────────
class WebSearchTool(BaseTool):
name = "WebSearch"
description = (
@@ -96,8 +87,6 @@ class WebSearchTool(BaseTool):
body = resp.text
# Parse result blocks DuckDuckGo wraps each result in
# <div class="result ..."> ... </div>
result_blocks = re.findall(
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*result|$)',
body,
@@ -109,14 +98,13 @@ class WebSearchTool(BaseTool):
if len(entries) >= num_results:
break
# Title + URL — handle both class-before-href and href-before-class
# Handle both class-before-href and href-before-class attribute orders.
link_match = re.search(
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
if not link_match:
# Try reversed attribute order
link_match = re.search(
r'<a[^>]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
block,
@@ -128,7 +116,6 @@ class WebSearchTool(BaseTool):
raw_url = html.unescape(link_match.group(1))
title = _strip_html(link_match.group(2)).strip()
# Snippet
snippet_match = re.search(
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
block,
@@ -136,7 +123,7 @@ class WebSearchTool(BaseTool):
)
snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
# DuckDuckGo wraps URLs through a redirect; try to extract the real URL
# DDG wraps URLs in a redirect; extract the real one.
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
if real_url_match:
from urllib.parse import unquote
@@ -152,11 +139,6 @@ class WebSearchTool(BaseTool):
return "\n\n".join(entries)
# ───────────────────────────────────────────────────────────────────────────
# WebFetchTool
# ───────────────────────────────────────────────────────────────────────────
class WebFetchTool(BaseTool):
name = "WebFetch"
description = (
@@ -202,10 +184,7 @@ class WebFetchTool(BaseTool):
is_html = "html" in content_type or resp.text.strip().startswith("<!")
if is_html:
# Prefer trafilatura for article/main-content extraction — strips
# nav, footer, ads, sidebars and returns the primary text. Falls
# back to regex HTML-strip if trafilatura can't extract (rare
# pages: pure apps, login walls, heavily JS-rendered content).
# Prefer trafilatura for main-content extraction; fall back to regex strip on apps/login walls/JS-heavy pages.
text: str | None = None
try:
import trafilatura # type: ignore
+2 -25
View File
@@ -1,25 +1,5 @@
#!/usr/bin/env python3
"""
Stdio MCP server exposing `WebSearch` and `WebFetch` backed by the
OpenSwarm backend's free DuckDuckGo + trafilatura implementation.
Purpose: the Claude Code CLI's built-in `WebSearch` / `WebFetch` tools
wrap Anthropic's server-side `web_search_20250305` / `web_fetch_20250807`
which require a Claude credential somewhere (Claude subscription on
9Router, openswarm-pro cloud proxy, or direct Anthropic API key). Users
who only connect ChatGPT Plus or Gemini Advanced and don't have any
Claude-backed credential get "No credentials for provider: claude" from
the CLI and either see hallucinated or empty results.
This server is registered by `agent_manager.py` only in that gap case.
When it is registered, the built-in `WebSearch` / `WebFetch` are added
to `disallowed_tools` so the model picks our MCP-prefixed versions.
Proxies tool calls to the backend at /api/web/search and /api/web/fetch
so the DDG / trafilatura logic lives in one place
(`backend/apps/agents/tools/web.py`) and can be evolved without
restarting the MCP subprocess.
"""
"""Stdio MCP server exposing WebSearch/WebFetch; registered only when no Claude credential is available."""
import json
import os
@@ -32,10 +12,7 @@ BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
SEARCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/search"
FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch"
# Primary-provider hint set by agent_manager at spawn time. Lets the
# backend pick the corresponding native search tool (googleSearch for
# Gemini, web_search_preview for OpenAI) — so searches come out of the
# budget the user is already paying for.
# Primary-provider hint from agent_manager; backend picks the native search tool (googleSearch/web_search_preview) so searches use the user's existing budget.
PRIMARY_HINT = os.environ.get("OPENSWARM_PRIMARY_API", "") or None
TOOLS = [
+7 -64
View File
@@ -9,19 +9,7 @@ logger = logging.getLogger(__name__)
class ConnectionManager:
"""Manages WebSocket connections and bridges HITL approval requests.
Every outbound event flows through the seq log so reconnecting
clients can replay missed events. The send happens *under* the
per-session lock yielded by `seq_log.stamp(...)`, which guarantees
wire order matches seq order even under concurrent broadcasts.
A WS disconnect (`disconnect_session`) ONLY removes the socket
from the connection registry. It does NOT cancel the underlying
agent task. The task lives on `agent_manager.tasks`; only an
explicit `agent:stop`, REST `/close`, natural completion, or
process shutdown ends a run.
"""
"""Manages WebSocket connections and HITL approval bridging; events flow through seq_log so reconnects can replay."""
def __init__(self):
self.connections: dict[str, list[WebSocket]] = {}
@@ -53,19 +41,7 @@ class ConnectionManager:
]
async def send_to_session(self, session_id: str, event: str, data: dict):
"""Broadcast a session event with monotonic sequencing.
The send to every socket happens inside the seq_log lock so a
slow/dead WS doesn't reorder events on the fast ones. If a
single send raises (broken pipe, half-open socket), we log and
continue the ring buffer still has the event so the client
will replay it on reconnect.
For terminal status events (completed/stopped/error) we also
atomically persist the payload to disk; a client that returns
after a process restart can then resolve the spinner via
`seq_log.load_terminal(...)` instead of being stuck.
"""
"""Broadcast a session event with monotonic sequencing; terminal statuses also persist to disk."""
async with seq_log.stamp(session_id, event, data) as (seq, payload_str):
for ws in list(self.connections.get(session_id, [])):
try:
@@ -77,38 +53,17 @@ class ConnectionManager:
await ws.send_text(payload_str)
except Exception:
logger.debug("send_to_session: global send failed", exc_info=True)
# Persist terminal events under the lock so a concurrent
# `agent:status: running` can't race past and overwrite
# the disk file with a stale state.
# Persist under the lock so a concurrent running status can't race past and overwrite with stale state.
if event == "agent:status" and data.get("status") in TERMINAL_STATUSES:
seq_log.persist_terminal(session_id, payload_str)
async def replay_to(
self, session_id: str, websocket: WebSocket, last_seq: int
) -> dict:
"""Replay buffered events with seq > last_seq to one socket.
Returns a small ack envelope describing what happened so the
caller (the WS handler) can send a `server:resume_ack` frame.
Three cases:
1. `events` non-empty: replay them in order; ack carries
`from_seq`, `to_seq`.
2. No buffer at all (process restarted, session evicted)
but a persisted terminal exists: send it; ack signals
`terminal_only=True`.
3. `last_seq` predates the oldest buffered seq: emit
`agent:gap_detected`; client REST-refreshes the session.
"""
"""Replay buffered events with seq > last_seq; returns ack envelope for the resume handshake."""
oldest, newest, events = seq_log.replay(session_id, last_seq)
# Check for gap FIRST. If the client's last_seq is below the
# buffer's oldest seq, we can't deliver everything they
# missed — silently replaying only the in-buffer tail would
# leave a hole in their state. Tell them to REST-refresh
# instead, even if the tail looks safe to send.
# Treat last_seq=0 as "fresh client" — they want a full
# replay of whatever's in the buffer, not a gap signal.
# Gap-check first: if last_seq predates the buffer, signal REST-refresh; last_seq=0 means fresh client (full replay).
if last_seq > 0 and oldest is not None and last_seq < oldest - 1:
gap_payload = json.dumps({
"event": "agent:gap_detected",
@@ -145,7 +100,6 @@ class ConnectionManager:
"to_seq": newest,
}
# Nothing in memory. Try a persisted terminal event.
terminal = seq_log.load_terminal(session_id)
if terminal is not None:
try:
@@ -154,7 +108,6 @@ class ConnectionManager:
pass
return {"ok": True, "replayed": 1, "terminal_only": True}
# Nothing missed, nothing to replay. Caller's caught up.
return {
"ok": True,
"replayed": 0,
@@ -162,12 +115,7 @@ class ConnectionManager:
}
async def broadcast_global(self, event: str, data: dict):
"""Send a message to all global (dashboard) connections.
Dashboard-scoped events don't go through the per-session seq
log they're not session-bound and the dashboard WS has its
own resume story (full state refetch on reconnect).
"""
"""Send to all dashboard connections; bypasses seq_log (dashboard resumes via full state refetch)."""
payload = json.dumps({"event": event, "data": data})
for ws in list(self.global_connections):
try:
@@ -179,12 +127,7 @@ class ConnectionManager:
self, session_id: str, request_id: str, tool_name: str, tool_input: dict,
timeout: float = 600.0,
) -> dict:
"""Send an approval request and wait for the user's response.
Returns the approval decision dict. Times out after `timeout`
seconds (default 10 minutes) so a forgotten request doesn't
permanently park the agent.
"""
"""Send an approval request and wait for the user's decision; 10-minute timeout prevents permanent park."""
future = asyncio.get_event_loop().create_future()
self.pending_futures[request_id] = future
+9 -9
View File
@@ -15,7 +15,7 @@ POST /api/auth/signout
identity fields. Brings the user back to the sign-in gate.
POST /api/auth/identity-status {install_id?}
Local proxy to cloud /api/me/identity-status drives the gate's
Local proxy to cloud /api/me/identity-status; drives the gate's
soft-vs-hard decision. Wraps it in our local backend so the renderer
doesn't need to know the cloud URL.
"""
@@ -88,8 +88,8 @@ async def signin_activate(body: SigninActivateRequest):
The bearer-handoff page (cloud lib/authMint.ts bearerHandoffPage())
POSTs to this endpoint after a Google OAuth or magic-link flow. We
re-validate the bearer with the cloud never just trust whatever
arrives at the localhost endpoint then write user_id + email +
re-validate the bearer with the cloud; never just trust whatever
arrives at the localhost endpoint; then write user_id + email +
signin_method to settings so the renderer can dismiss the gate.
"""
if not body.token or len(body.token) < 16:
@@ -134,7 +134,7 @@ async def signin_activate(body: SigninActivateRequest):
# If the user happens to be a paying customer too (Stripe + sign-in
# share a user row by email), surface plan/expires so the chat picker
# exposes Pro models. Free-tier signups land here with plan="free"
# and expires=null connection_mode stays own_key.
# and expires=null; connection_mode stays own_key.
if isinstance(plan, str) and plan != "free":
settings_obj.connection_mode = "openswarm-pro"
settings_obj.openswarm_bearer_token = body.token
@@ -145,7 +145,7 @@ async def signin_activate(body: SigninActivateRequest):
else:
# Free-tier: still store the bearer so future API calls can identify
# the user (used by /api/me/profile, /api/auth/signout). Do NOT flip
# connection_mode that's reserved for paid plans only so chat
# connection_mode; that's reserved for paid plans only so chat
# routing keeps using own_key/BYO.
settings_obj.openswarm_bearer_token = body.token
settings_obj.openswarm_proxy_url = proxy
@@ -199,7 +199,7 @@ async def signout():
# the previous identity's Claude account; resuming against the new
# bearer would 404 or 401 because the new account has no record
# of that thread. Wiping it forces the SDK to start a fresh thread
# on next send (transcript replay still works only the SDK's
# on next send (transcript replay still works; only the SDK's
# server-side resume cache is reset).
# Best-effort: failures here shouldn't block the sign-out itself.
try:
@@ -266,10 +266,10 @@ async def identity_status():
"hard_gate": False,
}
# Not signed in defer to cloud for install-age + grace-window math.
# Not signed in; defer to cloud for install-age + grace-window math.
install_id = getattr(settings_obj, "installation_id", None)
if not install_id:
# No install_id yet (very fresh install before first sync) hard gate.
# No install_id yet (very fresh install before first sync); hard gate.
return {"authed": False, "hard_gate": True, "install_age_days": 0, "deadline_ts": None}
proxy = _proxy_url()
@@ -290,6 +290,6 @@ async def identity_status():
except httpx.HTTPError as e:
logger.debug("identity-status cloud fetch failed: %s", e)
# Cloud unreachable fail open with soft gate so a flaky network
# Cloud unreachable; fail open with soft gate so a flaky network
# doesn't lock the user out. Renderer will retry on next mount.
return {"authed": False, "hard_gate": False, "install_age_days": 0, "deadline_ts": None}
+3 -3
View File
@@ -60,7 +60,7 @@ def _migrate_if_needed():
if existing:
return
logger.info("No dashboards found running one-time migration")
logger.info("No dashboards found; running one-time migration")
layout = DashboardLayout()
if os.path.exists(OLD_LAYOUT_FILE):
@@ -203,7 +203,7 @@ async def seed_orchestration_demo(dashboard_id: str):
agent for the user to attach to a new orchestrator. We seed a single
completed-looking session that pretends to have done research on
OpenSwarm, with messages mentioning what it found. The user then
drags it into a new agent and asks for a PDF report which
drags it into a new agent and asks for a PDF report; which
delegates back to this seeded agent.
"""
_load(dashboard_id) # validate dashboard exists
@@ -255,7 +255,7 @@ async def seed_orchestration_demo(dashboard_id: str):
"- A Hono cloud service handles auth, billing, and account pooling.\n"
"- Built-in browser cards let agents drive web pages directly.\n"
"- Skills and Apps let users teach the system new capabilities.\n\n"
"Ready when you are let me know what you'd like to do with this."
"Ready when you are; let me know what you'd like to do with this."
),
"timestamp": now.isoformat(),
"branch_id": "main",
+1 -3
View File
@@ -36,9 +36,7 @@ class BrowserCardPosition(BaseModel):
y: float = 0
width: float = 1280
height: float = 800
# Agent session id that spawned this browser, or None for user-created.
# Used by the frontend to auto-remove the browser when its owner agent
# reaches a terminal completed/error state.
# Spawning agent session id; frontend auto-removes the browser when this agent reaches a terminal state.
spawned_by: Optional[str] = None
+2 -2
View File
@@ -228,7 +228,7 @@ def _call(
locally so the user gets a clear error instead of an opaque 401.
"""
if not INSTALL_ID:
return 0, "OPENSWARM_INSTALL_ID env var not set cannot call Discord proxy"
return 0, "OPENSWARM_INSTALL_ID env var not set; cannot call Discord proxy"
url = f"{PROXY_BASE}/api/discord{path}"
if query:
@@ -282,7 +282,7 @@ def _check_guild(guild_id: str) -> str | None:
The set is sourced from OPENSWARM_DISCORD_GUILD_IDS env var (CSV) which
tools_lib.py populates from the tool's oauth_tokens.guilds. If the env
var is empty (no guild authorization yet), allow all agent shouldn't
var is empty (no guild authorization yet), allow all; agent shouldn't
be able to spawn this MCP without an OAuth flow having happened.
"""
if not ALLOWED_GUILDS:
-6
View File
@@ -13,16 +13,10 @@ async def health_lifespan():
health = SubApp("health", health_lifespan)
######################################
# Health Check Endpoints #
######################################
@health.router.get("/check")
@typechecked
async def check() -> PlainTextResponse:
debug("Health check successful")
# Use PlainTextResponse instead of JSONResponse for AWS ALB compatibility
# ALB health checks can be sensitive to JSON responses and Content-Length headers
return PlainTextResponse(
content="OK",
status_code=status.HTTP_200_OK,
+18 -18
View File
@@ -55,9 +55,9 @@ BUILTIN_MODES: list[Mode] = [
Mode(
id="ask",
name="Ask",
description="Read-only conversation. Browse the codebase, search the web, and discuss ideas but no edits, shells, or file writes.",
description="Read-only conversation. Browse the codebase, search the web, and discuss ideas; but no edits, shells, or file writes.",
system_prompt=(
"You are in Ask mode a read-only assistant. Keep responses "
"You are in Ask mode; a read-only assistant. Keep responses "
"natural and conversational. You CAN read files, search the "
"codebase, and search/fetch the web. You CANNOT edit files, run "
"shell commands, or otherwise modify anything; if the user asks "
@@ -88,21 +88,21 @@ BUILTIN_MODES: list[Mode] = [
name="App Builder",
description="Create and iterate on reusable App artifacts.",
system_prompt=(
"You are an App Builder an AI assistant that creates self-contained "
"You are an App Builder; an AI assistant that creates self-contained "
"web apps rendered in an iframe preview.\n\n"
"Your working directory is a dedicated workspace folder pre-seeded with "
"template files. Read the existing files before making changes.\n\n"
"## Critical rules\n\n"
"- The entry point MUST be named `index.html`. Never rename it or create "
"a different HTML file as the main entry point.\n"
"- Write files immediately when you have code ready the user sees a "
"- Write files immediately when you have code ready; the user sees a "
"live preview that auto-refreshes from these files.\n"
"- Always write the complete file content on first creation (do not use "
"Edit for partial patches on new files).\n"
"- For complex apps, split code into separate files (JS, CSS, etc.) "
"and reference them from index.html with relative paths.\n"
"- Always update meta.json with a short name and one-sentence description.\n"
"- Build beautiful, polished UIs with modern design dark themes, smooth "
"- Build beautiful, polished UIs with modern design; dark themes, smooth "
"transitions, proper spacing, and responsive layouts.\n\n"
"Read the SKILL.md reference in your workspace for the full technical "
"specification of the App platform (available globals, file conventions, "
@@ -120,17 +120,17 @@ BUILTIN_MODES: list[Mode] = [
name="Skill Builder",
description="Create and iterate on skills using AI-assisted vibe coding.",
system_prompt=(
"You are a Skill Builder an AI assistant that helps users create, "
"You are a Skill Builder; an AI assistant that helps users create, "
"refine, and iterate on Claude skills (SKILL.md files).\n\n"
"## How Skills Work\n\n"
"A skill is a Markdown file that teaches Claude how to perform a specific task. "
"Skills have YAML frontmatter with `name` and `description` fields, followed by "
"the skill body in Markdown. The description is the primary triggering mechanism "
"the skill body in Markdown. The description is the primary triggering mechanism; "
"it tells Claude when to use the skill.\n\n"
"## Your Working Directory\n\n"
"Your working directory is a dedicated workspace folder for this skill. "
"Write your output directly to these files using the Write tool:\n\n"
"1. **SKILL.md** The complete skill file with YAML frontmatter and Markdown body. "
"1. **SKILL.md**; The complete skill file with YAML frontmatter and Markdown body. "
"Example frontmatter:\n"
" ```\n"
" ---\n"
@@ -138,34 +138,34 @@ BUILTIN_MODES: list[Mode] = [
" description: When to trigger and what this skill does.\n"
" ---\n"
" ```\n\n"
"2. **meta.json** Metadata for the skill builder UI. Always write this file. Example:\n"
"2. **meta.json**; Metadata for the skill builder UI. Always write this file. Example:\n"
' {"name":"My Skill","description":"A short description","command":"my-skill"}\n\n'
"Write these files immediately when you have content ready. The user can see "
"a live preview that auto-refreshes from these files. Always write the "
"complete file content (do not use Edit for partial patches on first creation).\n\n"
"## Skill Creation Process\n\n"
"1. **Understand intent** Ask what the skill should do, when it should trigger, "
"1. **Understand intent**; Ask what the skill should do, when it should trigger, "
"and what the expected output format is.\n"
"2. **Draft the skill** Write a SKILL.md with clear instructions, examples, "
"2. **Draft the skill**; Write a SKILL.md with clear instructions, examples, "
"and good progressive disclosure.\n"
"3. **Iterate** Refine based on user feedback. Update the files each time.\n\n"
"3. **Iterate**; Refine based on user feedback. Update the files each time.\n\n"
"## Skill Writing Best Practices\n\n"
"- Keep SKILL.md under 500 lines; use bundled reference files for large content.\n"
"- The `description` frontmatter is the primary trigger. Make it slightly \"pushy\" "
"- The `description` frontmatter is the primary trigger. Make it slightly \"pushy\"; "
"include both what the skill does AND specific contexts for when to use it.\n"
"- Use imperative form in instructions.\n"
"- Include examples with input/output pairs when helpful.\n"
"- Define output formats explicitly with templates.\n"
"- Use theory of mind explain *why* things matter rather than just MUST directives.\n"
"- Use theory of mind; explain *why* things matter rather than just MUST directives.\n"
"- Think about edge cases, error handling, and progressive disclosure.\n\n"
"## Skill Anatomy\n\n"
"```\n"
"skill-name/\n"
"├── SKILL.md (required) YAML frontmatter + Markdown instructions\n"
"├── SKILL.md (required); YAML frontmatter + Markdown instructions\n"
"└── Bundled Resources (optional)\n"
" ├── scripts/ Executable code for repetitive tasks\n"
" ├── references/ Docs loaded into context as needed\n"
" └── assets/ Files used in output\n"
" ├── scripts/ ; Executable code for repetitive tasks\n"
" ├── references/; Docs loaded into context as needed\n"
" └── assets/ ; Files used in output\n"
"```\n\n"
"Be collaborative and flexible. If the user wants to \"just vibe\", skip the formal "
"process and iterate freely. Always write updated files so the preview stays current."
+1 -4
View File
@@ -14,10 +14,7 @@ from backend.config.paths import MODES_DIR as DATA_DIR
@asynccontextmanager
async def modes_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
# One-time migration: Chat was merged into Ask. Remove a stale built-in
# chat.json if it still has its is_builtin=True signature so users don't
# see two near-identical modes in the picker. Leave alone if a user has
# diverged it (we don't want to wipe customizations).
# Migration: Chat merged into Ask; drop a stale built-in chat.json but leave customized copies alone.
chat_path = os.path.join(DATA_DIR, "chat.json")
if os.path.exists(chat_path):
try:
+52 -141
View File
@@ -30,13 +30,13 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
# cross-provider WebSearch: the CLI's WebSearch call from Codex/Gemini
# primaries used to route cleanly through 9Router's translator and hit
# Anthropic's server-side web_search (returning real results), but later
# translator changes broke that path non-Claude primaries now see
# translator changes broke that path; non-Claude primaries now see
# "claude-haiku-4-5-20251001 unavailable" or hallucinated output.
# Pinning to 0.3.60 restores v1.0.25 behavior.
#
# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens)
# when translating Anthropic→OpenAI, which OpenAI's GPT-5 family rejects.
# The fix lives in our /api/openai-passthrough proxy see openai_passthrough.py
# The fix lives in our /api/openai-passthrough proxy; see openai_passthrough.py
# and sync_openai_api_key for how the translation lane is rerouted via an
# `openai-compatible` provider-node that honors `baseUrl`.
NINE_ROUTER_NPM_VERSION = "0.3.60"
@@ -75,16 +75,12 @@ def _find_9router_dir() -> str | None:
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
if _is_packaged:
# Packaged Electron app — router is in extraResources
import sys
# In packaged mode, backend is at <resources>/backend/
# So router is at <resources>/router/
_resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_candidate = os.path.join(_resources, "router")
if os.path.isdir(_candidate):
return _candidate
else:
# Dev mode — router is at project root
_backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_project_root = os.path.dirname(_backend_dir)
_candidate = os.path.join(_project_root, "router")
@@ -103,7 +99,7 @@ def _gpt5_patch_path() -> str | None:
every gpt-5* own-key session 400's because OpenAI rejects the legacy
field name and 9router (every version including 0.4.20) emits it.
Returns None if the file is missing `subprocess.Popen` would fail
Returns None if the file is missing; `subprocess.Popen` would fail
on `node --require <missing-path>`, so the caller drops the flag and
spawns 9router unpatched (failure mode = identical to pre-patch
baseline; GPT-5 still 400's but everything else works).
@@ -121,14 +117,14 @@ def _find_node() -> str | None:
"""Find a Node.js binary (works in both dev and packaged mode).
Priority order:
1. OPENSWARM_NODE_PATH set by electron/main.js when a real Node
1. OPENSWARM_NODE_PATH; set by electron/main.js when a real Node
binary is bundled in extraResources. Always preferred on user
machines because it (a) avoids the bouncing "exec" Dock icon
that ELECTRON_RUN_AS_NODE produces on fresh Macs and (b) starts
in ~50ms vs Electron-as-Node's 515s cold-start, shrinking the
in ~50ms vs Electron-as-Node's 5, 15s cold-start, shrinking the
splash window the user stares at.
2. System `node` on PATH dev convenience.
3. ELECTRON_RUN_AS_NODE fallback last resort. Only hits this on
2. System `node` on PATH; dev convenience.
3. ELECTRON_RUN_AS_NODE fallback; last resort. Only hits this on
packaged builds that for some reason shipped without the bundled
node payload.
"""
@@ -163,7 +159,7 @@ def _ensure_router_cached() -> str | None:
"""Ensure the npm 9router package is installed in the dev cache.
Returns the absolute path to `app/server.js` on success, or None if
npm isn't available or the install fails. Idempotent returns
npm isn't available or the install fails. Idempotent; returns
immediately when the server file already exists.
Running `node app/server.js` directly (instead of `npx 9router`)
@@ -178,7 +174,7 @@ def _ensure_router_cached() -> str | None:
npm = shutil.which("npm")
if not npm:
logger.warning("npm not found install Node.js to auto-start 9Router in dev.")
logger.warning("npm not found; install Node.js to auto-start 9Router in dev.")
return None
try:
@@ -242,7 +238,7 @@ async def ensure_running():
_9router_dir = _find_9router_dir()
if _is_packaged and _9router_dir:
# Packaged mode run the pre-built standalone server staged at
# Packaged mode; run the pre-built standalone server staged at
# <resources>/router/server.js by scripts/fetch-router.sh at build time.
standalone_server = os.path.join(_9router_dir, "server.js")
if not os.path.exists(standalone_server):
@@ -253,7 +249,7 @@ async def ensure_running():
node = _find_node()
if not node:
logger.warning("Node.js not found cannot start 9Router in packaged mode.")
logger.warning("Node.js not found; cannot start 9Router in packaged mode.")
return
logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT)
@@ -268,7 +264,7 @@ async def ensure_running():
env["ELECTRON_RUN_AS_NODE"] = "1"
else:
# Dev mode install the pinned 9router npm package into a local
# 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,
@@ -280,7 +276,7 @@ async def ensure_running():
node = _find_node()
if not node:
logger.warning("Node.js not found cannot start 9Router in dev mode.")
logger.warning("Node.js not found; cannot start 9Router in dev mode.")
return
logger.info(
@@ -298,7 +294,7 @@ async def ensure_running():
# 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
# 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"):
_log_path = os.path.join(
@@ -323,7 +319,6 @@ async def ensure_running():
env=env,
)
# Wait up to 30 seconds for startup (production standalone is faster)
timeout = 20 if _is_packaged else 30
for _ in range(timeout * 2):
await asyncio.sleep(0.5)
@@ -352,10 +347,6 @@ def stop():
logger.info("9Router stopped")
# ---------------------------------------------------------------------------
# API proxy helpers — call 9Router's API from OpenSwarm
# ---------------------------------------------------------------------------
async def get_usage_stats(period: str = "all") -> dict | None:
"""Get usage statistics from 9Router."""
try:
@@ -378,8 +369,8 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No
in reverse chronological order with full token breakdowns including
`reasoning_tokens` (OpenAI's `completion_tokens_details.reasoning_tokens`)
and `thoughtsTokenCount` (Gemini's). For Anthropic via 9Router this
field will be absent/zero Anthropic doesn't break out reasoning
tokens in its API response so callers get None and should fall
field will be absent/zero; Anthropic doesn't break out reasoning
tokens in its API response; so callers get None and should fall
back to the heuristic.
"""
if not is_running():
@@ -393,9 +384,6 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No
if r.status_code != 200:
return None
data = r.json()
# Endpoint returns either {requests: [...]} or {data: [...]} —
# be defensive about the shape since 9Router has rolled out
# multiple variants.
requests = data.get("requests") or data.get("data") or []
for req in requests:
tokens = req.get("tokens") or req.get("usage") or {}
@@ -415,7 +403,7 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No
async def get_providers() -> list[dict]:
"""Get all providers and their connection status from 9Router.
9Router's GET /api/providers returns `{"connections": [...]}` we
9Router's GET /api/providers returns `{"connections": [...]}`; we
unwrap so callers always see a plain list of connection dicts.
"""
try:
@@ -432,21 +420,11 @@ async def get_providers() -> list[dict]:
return []
# ---------------------------------------------------------------------------
# API-key connection sync (Gemini AI Studio, etc.)
# ---------------------------------------------------------------------------
#
# 9Router supports both OAuth (e.g. gemini-cli) and direct API-key auth
# (provider="gemini", authType="apikey"). The two hit different Google
# quotas — OAuth uses the Code Assist free tier which is aggressively
# rate-limited (429s on Gemini 3 Pro/Flash even for paid-subscription
# users), while an AI Studio API key uses the generativelanguage.googleapis.com
# quota which is independent and far higher.
#
# We expose `google_api_key` in settings; this helper mirrors it into
# 9Router's provider-connections list so the API-key path is preferred
# when a key is set. On removal, we delete the key-based connection so
# 9Router falls back to whatever OAuth connection the user still has.
# API-key auth (provider="gemini", authType="apikey") and OAuth hit different
# Google quotas: OAuth uses the Code Assist free tier (aggressively rate-limited;
# 429s on Gemini 3 Pro/Flash even for paid users), while an AI Studio API key
# uses generativelanguage.googleapis.com (independent and far higher). We mirror
# google_api_key into 9Router so the API-key path is preferred when a key is set.
NINE_ROUTER_KEYED_NAME = "AI Studio (OpenSwarm-managed)"
NINE_ROUTER_OPENAI_KEYED_NAME = "OpenAI (OpenSwarm-managed)"
@@ -533,7 +511,7 @@ async def sync_openai_api_key(api_key: str | None) -> None:
`baseUrl` field on the connection. Only the `openai-compatible-*`
provider-node type honors `baseUrl` (verified statically against
9Router's compiled bundle). So we register our OpenAI lane AS an
openai-compatible node same upstream protocol, different routing.
openai-compatible node; same upstream protocol, different routing.
Why we route through openai-passthrough at all: OpenAI's GPT-5 family
rejects the legacy `max_tokens` parameter with HTTP 400, but every
@@ -565,7 +543,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
base_url = f"http://127.0.0.1:{port}/api/openai-passthrough/v1"
managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}"
# List existing managed nodes — we own the prefix `cp-openai`.
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{NINE_ROUTER_API}/provider-nodes")
@@ -578,7 +555,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
None,
)
# Tear down when api_key is cleared.
if not api_key:
if existing_node:
try:
@@ -623,7 +599,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
logger.warning(f"9Router OpenAI compat node sync failed: {e}")
return
# Connection record carrying the api key, scoped to this provider node.
try:
existing_conn = await _find_keyed_connection(node_id, managed_name)
conn_payload = {
@@ -657,19 +632,10 @@ async def sync_openrouter_api_key(api_key: str | None) -> None:
)
# ---------------------------------------------------------------------------
# Custom OpenAI-compatible providers (Ollama Cloud, Together AI, local Ollama, etc.)
# ---------------------------------------------------------------------------
#
# 9Router supports arbitrary OpenAI-compatible endpoints via "provider nodes"
# (POST /api/provider-nodes with type="openai-compatible"). Each node gets a
# unique provider id like `openai-compatible-chat-<rand>` and a user-defined
# `prefix`. At request time, model_id `<prefix>/<bare_model>` routes to that
# node's baseUrl, with auth from a connection of the node's provider type.
#
# We mirror settings.custom_providers[] into 9Router with prefix `cp-<slug>`,
# letting us address each provider as `cp-<slug>/<model_id>` without colliding
# with the user's primary OpenAI key (different provider type).
# 9Router exposes arbitrary OpenAI-compatible endpoints via "provider nodes"
# (POST /api/provider-nodes, type="openai-compatible"). model_id <prefix>/<model>
# routes to that node's baseUrl. We mirror settings.custom_providers[] with
# prefix `cp-<slug>` so they don't collide with the user's primary OpenAI key.
NINE_ROUTER_CUSTOM_NAME_SUFFIX = " (OpenSwarm-managed)"
@@ -711,19 +677,14 @@ async def sync_custom_providers(providers: list) -> None:
seen_prefixes: set[str] = set()
for cp in providers or []:
# Tolerate both Pydantic instances and plain dicts.
name = getattr(cp, "name", None) or (cp.get("name") if isinstance(cp, dict) else None) or ""
base_url = getattr(cp, "base_url", None) or (cp.get("base_url") if isinstance(cp, dict) else None) or ""
api_key = getattr(cp, "api_key", None) or (cp.get("api_key") if isinstance(cp, dict) else None) or ""
if not name.strip() or not base_url.strip():
continue
# Local OpenAI-compatible servers (LM Studio, Ollama, vLLM, llama.cpp,
# text-generation-webui, etc.) ship with auth disabled by default —
# they ignore the Authorization header entirely. But 9Router still
# creates the connection with `authType: "apikey"` and would send a
# blank Bearer if api_key is "", which some servers reject as a
# malformed header. Substitute a harmless placeholder when blank;
# servers that DO require auth always have api_key set anyway.
# Local OpenAI-compat servers (LM Studio, Ollama, etc.) reject a blank
# Bearer header even with auth disabled. Substitute a placeholder; real
# auth deployments always have api_key set.
api_key = api_key.strip() or "no-auth-required"
slug = _custom_provider_slug(name)
prefix = f"cp-{slug}"
@@ -765,7 +726,6 @@ async def sync_custom_providers(providers: list) -> None:
logger.warning(f"9Router custom node {prefix} sync failed: {e}")
continue
# Ensure a connection exists for this provider node carrying the apikey.
try:
existing_conn = await _find_keyed_connection(node_id, managed_name)
conn_payload = {
@@ -793,8 +753,7 @@ async def sync_custom_providers(providers: list) -> None:
except Exception as e:
logger.warning(f"9Router custom connection {prefix} sync failed: {e}")
# Drop managed nodes that no longer correspond to any settings entry.
# DELETE on a node cascades to its connections.
# Drop managed nodes no longer in settings; DELETE cascades to connections.
for prefix, node in managed_by_prefix.items():
if prefix in seen_prefixes:
continue
@@ -818,13 +777,13 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
path for openswarm-pro users, so the search fails with
"no credentials for provider: claude". With this sync, 9Router sees
the OpenSwarm-Pro-backed Claude connection and routes the search
call through our cloud same quota the user's Pro subscription
call through our cloud; same quota the user's Pro subscription
already covers, no extra cost."""
if not is_running():
return
# 9Router's POST /api/providers only accepts direct-API provider ids
# for apikey auth `claude` is the subscription/IDE id, `anthropic`
# for apikey auth; `claude` is the subscription/IDE id, `anthropic`
# is the direct-API id. Use `anthropic`.
existing = await _find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME)
try:
@@ -865,31 +824,15 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
logger.warning(f"9Router OpenSwarm-Pro Claude sync failed: {e}")
# ---------------------------------------------------------------------------
# Per-provider OAuth redirect URIs
# ---------------------------------------------------------------------------
#
# Each upstream OAuth client is registered with the identity provider against
# a specific redirect URI. Anthropic's Claude Code client is lenient — any
# `http://localhost:*/callback` works — so we can use 9Router's built-in
# callback page at port 20128 for it. OpenAI's Codex client is NOT: it's
# registered with `http://localhost:1455/auth/callback` and OpenAI rejects
# any other redirect_uri with `unknown_error` at the auth page. Google's
# Gemini CLI client accepts arbitrary localhost URIs so we keep 20128 there.
#
# For Codex specifically we spawn a one-shot HTTP listener on port 1455
# below that serves a callback page mirroring 9Router's callback page —
# postMessage to window.opener, BroadcastChannel fan-out, then close. This
# lets the frontend reuse its existing Claude/Anthropic flow unchanged
# (window.open popup + postMessage handler in Settings.tsx).
# OpenAI's Codex OAuth client is registered with a fixed redirect URI
# `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`.
# Anthropic and Google's clients accept arbitrary localhost callbacks (we use
# 9Router's 20128 callback page). For Codex we spawn a one-shot listener on
# 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so
# the frontend's existing popup + msgHandler flow works unchanged.
_CODEX_CALLBACK_PORT = 1455
_CODEX_CALLBACK_PATH = "/auth/callback"
# Minimal callback page inlined as bytes. Mirrors 9router/src/app/callback/
# page.js:27-55 — posts the OAuth data to window.opener via postMessage,
# BroadcastChannel, and localStorage so whatever detection path the caller
# is using will fire. Served to the Electron popup that OAuth redirects to.
_CODEX_CALLBACK_HTML = b"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Authorization Complete</title>
<style>body{font-family:-apple-system,system-ui,sans-serif;background:#111;color:#eee;
@@ -930,7 +873,7 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the
callback (or after `timeout` seconds with no callback) the listener
closes itself in a background task. Safe to call even if 1455 is busy
closes itself in a background task. Safe to call even if 1455 is busy ,
logs the collision and returns None so start_oauth can still proceed and
surface whatever error OpenAI returns.
@@ -940,7 +883,7 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
stuck on "Connecting…" until the 30s timeout fires. Exchanging here
(the same pattern backend/main.py uses for the Gemini callback) makes
the connection land in 9Router's DB regardless of whether the UI's
postMessage listener ever gets notified the Settings / OnboardingModal
postMessage listener ever gets notified; the Settings / OnboardingModal
status pollers then pick it up within a couple seconds.
"""
@@ -951,7 +894,6 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
# Read the request line ("GET /auth/callback?... HTTP/1.1\r\n")
raw_request_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
request_line = raw_request_line.decode("latin-1", errors="replace").strip()
# Drain headers so the browser's request is fully consumed
while True:
line = await asyncio.wait_for(reader.readline(), timeout=5.0)
if not line or line in (b"\r\n", b"\n"):
@@ -1024,7 +966,6 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
await writer.drain()
callback_served.set()
else:
# Unrelated request (favicon, preflight) — 404 and move on
writer.write(
b"HTTP/1.1 404 Not Found\r\n"
b"Content-Length: 0\r\n"
@@ -1043,7 +984,7 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
try:
server = await asyncio.start_server(_handle, "127.0.0.1", _CODEX_CALLBACK_PORT)
except OSError as e:
# Port already in use probably another Codex connect attempt still
# Port already in use; probably another Codex connect attempt still
# running, or an actual Codex CLI process holding 1455. Log and bail.
logger.warning(
f"Could not start Codex callback listener on port {_CODEX_CALLBACK_PORT}: {e}. "
@@ -1074,38 +1015,15 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
return server
# Providers that cannot use the in-Electron `window.open` popup flow and
# must be opened in the user's system browser instead.
#
# Google enforces an "Embedded WebView Restrictions" policy on its OAuth
# consent pages that uses JS-based fingerprinting, not just user-agent
# sniffing. We tried defeating it with a combination of Chrome UA spoof +
# sandboxed webPreferences + fresh session partition + a preload script
# that patches navigator.webdriver/plugins/mimeTypes/languages/chrome and
# overrides navigator.permissions.query — it was still rejected. Google's
# detection is a moving target and actively adversarial. The supported
# workaround (and what Google recommends for Desktop app OAuth) is to run
# the flow in the user's real browser via shell.openExternal.
#
# When a provider is in this set the frontend calls
# window.openswarm.openExternal (shell.openExternal) instead of
# window.open, and the callback lands on OpenSwarm's own
# /api/subscriptions/callback endpoint (backend/main.py:138) which
# exchanges the code and serves a "Connected!" page. Detection on the
# OpenSwarm side happens via the existing status poller on the
# Settings page.
# Providers that hand off to the user's default browser instead of using
# our embedded Electron popup:
# - gemini-cli, antigravity: Google blocks embedded browsers wholesale
# ("Your browser is not supported anymore") — no UA spoof defeats it.
# - codex: OpenAI's auth.openai.com renders blank inside our popup on
# some users' machines (likely a mix of newer embed detection and
# regional access checks) and the system browser surfaces the real
# error rather than a silent blank window. Also what RFC 8252 mandates
# for native-app OAuth, so this is the correct long-term shape anyway.
# Codex's callback URL is special-cased below to stay on localhost:1455
# (OpenAI's hardcoded redirect URI) — the listener catches the system
# browser's redirect just like it caught the popup's.
# Providers whose OAuth flow MUST run in the user's real browser via
# shell.openExternal, not the in-Electron window.open popup:
# - gemini-cli, antigravity: Google's Embedded WebView Restrictions policy uses
# JS-fingerprint detection that no UA spoof defeats. RFC 8252 and Google's
# own Desktop-app OAuth guidance both prescribe the system browser.
# - codex: auth.openai.com renders blank in our popup on some machines (newer
# embed detection + regional checks); system browser surfaces the real error.
# The callback for gemini-cli/antigravity lands on /api/subscriptions/callback
# and runs the exchange server-side; codex uses its fixed 1455 listener.
_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex"}
@@ -1133,7 +1051,7 @@ def _callback_uri_for_provider(provider: str) -> str:
Most providers accept 9Router's built-in callback page at port 20128.
Two special cases:
- Codex/OpenAI's OAuth client is bound to a fixed
http://localhost:1455/auth/callback URI handled by
http://localhost:1455/auth/callback URI; handled by
_start_codex_callback_listener above.
- Gemini/Google's OAuth consent page rejects embedded browsers, so we
route the callback through OpenSwarm's backend endpoint at
@@ -1155,7 +1073,6 @@ async def start_oauth(provider: str) -> dict:
For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state}
"""
async with httpx.AsyncClient(timeout=15.0) as client:
# Try device-code flow first
try:
r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
if r.status_code == 200:
@@ -1171,12 +1088,6 @@ async def start_oauth(provider: str) -> dict:
except Exception:
pass
# Authorization code flow. Most providers accept 9Router's own
# callback page at port 20128, but Codex's OAuth client is bound
# to a fixed http://localhost:1455/auth/callback URI — spawn an
# in-process listener on that port before returning the auth URL,
# so the popup can redirect there after login and relay the code
# back to the frontend via postMessage (same flow as Claude).
callback_url = _callback_uri_for_provider(provider)
if provider == "codex":
await _start_codex_callback_listener()
+10 -10
View File
@@ -15,7 +15,7 @@ TIMEOUT_SECONDS = 30
# can find ways around this (e.g. string-encoded imports via tricks the AST
# validator can't see), but the allowlist kills the easy paths cheaply and
# pairs with cwd=tempdir + minimal env so the blast radius is small even if
# a payload slips past. Keep this list to "data shaping" libraries no I/O,
# a payload slips past. Keep this list to "data shaping" libraries; no I/O,
# no networking, no subprocess.
_ALLOWED_MODULES = frozenset({
"json", "math", "re", "datetime", "collections", "itertools",
@@ -43,9 +43,9 @@ def get_code_warnings(code: str) -> list[str]:
"""Return human-readable warnings for AST-visible risks, without raising.
Used by `/api/outputs/execute` to surface risks to the user in the run
dialog before executing so a legit Output that needs `pandas` doesn't
dialog before executing; so a legit Output that needs `pandas` doesn't
silently 500 with "import not allowed," it gets a "this Output uses
unsafe imports review and click Run Anyway" affordance.
unsafe imports; review and click Run Anyway" affordance.
Returns [] for code that's fully inside the allowlist. A syntax error
is reported as a single warning rather than raised so the dialog can
@@ -97,7 +97,7 @@ def _validate_code_safety(code: str) -> None:
# Env vars we always scrub from the subprocess, regardless of strict-vs-force.
# These are the keys an attacker would actually want install token, provider
# These are the keys an attacker would actually want; install token, provider
# API keys, cloud credentials. Everything else is local-machine convenience.
_SCRUBBED_ENV_KEYS = frozenset({
"OPENSWARM_AUTH_TOKEN",
@@ -120,13 +120,13 @@ def _minimal_env(force: bool = False) -> dict:
"""Build the env for the executor subprocess.
Strict mode (force=False): only language essentials. AST-validated code
is data-shaping only `import os` and `open()` are blocked, so the
is data-shaping only; `import os` and `open()` are blocked, so the
subprocess can't read env vars or expand `~` anyway. Minimal env is
correct here.
Force mode (force=True): user has explicitly approved unsafe imports
via the HITL preview. They expect the code to behave like a normal
Python process read HOME, find files, etc. Inherit the real env
Python process; read HOME, find files, etc. Inherit the real env
minus credentials, so an `open(os.path.expanduser("~/data.csv"))`
actually works instead of silently misbehaving.
@@ -166,7 +166,7 @@ async def execute_backend_code(
result to a global ``result`` dict. User print() calls are captured
separately from the result via an in-process StringIO redirect.
Security boundaries (defense in depth none alone is sufficient):
Security boundaries (defense in depth; none alone is sufficient):
1. AST allowlist on imports + blocked-builtin call list.
2. Subprocess cwd = fresh temp dir (not the OpenSwarm process cwd).
3. Subprocess env strips PATH, all *_TOKEN / *_API_KEY inheritance.
@@ -174,9 +174,9 @@ async def execute_backend_code(
to catch AST-bypass tricks (e.g. metaclass shenanigans).
5. 30s wall-clock timeout, killed on overrun.
`skip_validation=True` bypasses #1 intended ONLY for callers that
`skip_validation=True` bypasses #1; intended ONLY for callers that
have already surfaced the warnings to a user and gotten explicit
consent (the `/api/outputs/execute` HITL flow). #2#5 always run.
consent (the `/api/outputs/execute` HITL flow). #2, #5 always run.
"""
if not skip_validation:
@@ -186,7 +186,7 @@ async def execute_backend_code(
"import json, sys, io, builtins\n"
# Defense-in-depth: scrub dangerous attrs off `builtins` so
# attribute-style accesses (metaclass.__subclasses__ chains) can't
# reach them. NOTE: __import__ is deliberately NOT scrubbed
# reach them. NOTE: __import__ is deliberately NOT scrubbed ,
# Python's `import` statement bytecode reads `__import__` from
# builtins, so removing it makes EVERY import (including allowlisted
# ones like `import math`) fail with "ImportError: __import__ not
+1 -1
View File
@@ -127,7 +127,7 @@ class OutputExecute(BaseModel):
# running if the backend code touches anything outside the safe
# data-shaping allowlist. The UI shows those warnings to the user and
# re-submits with force=True after they click "Run Anyway." This is
# a UX gate, not a security one anyone holding the auth token can
# a UX gate, not a security one; anyone holding the auth token can
# set force=True; the value is providing the user explicit visibility
# of what's about to execute.
force: bool = False
+15 -15
View File
@@ -139,7 +139,7 @@ def _inject_token_into_relative_urls(html: str, token: str) -> str:
relative `<link href="styles.css">` / `<script src="x.js">`, so without
this rewrite the sub-resource fetch lands at the auth middleware with no
credentials and gets a 401. Idempotent: skips URLs that already carry a
`token=` param. Skips absolute URLs (CDN, data:, etc.) see prefix list.
`token=` param. Skips absolute URLs (CDN, data:, etc.); see prefix list.
"""
if not token:
return html
@@ -222,7 +222,7 @@ def load_output(output_id: str) -> Output | None:
# descend into. Without this skip-list the workspace endpoint reads
# `node_modules/` (300 MB of MUI source, when it's a real dir and not a
# symlink), `.venv/` (10k+ Python files from the hardlinked cache),
# `__pycache__/`, `dist/`, `.git/`, etc every 2 seconds while the
# `__pycache__/`, `dist/`, `.git/`, etc; every 2 seconds while the
# agent is active. Result: backend CPU pegged on JSON-serializing
# auto-generated chunks the frontend will then throw away. The frontend
# already filters these for display; this skip is the real fix.
@@ -253,14 +253,14 @@ _WALK_MAX_FILE_BYTES = 256 * 1024
def _walk_directory(folder: str) -> dict[str, str]:
"""Walk a directory tree and return {relative_path: content} for all
text files the user is actually authoring. Skips build/install
directories AND truncates oversize files both critical for the
directories AND truncates oversize files; both critical for the
polling endpoint, which is called every 2 s while the agent is
writing code and would otherwise serialize hundreds of MB per poll."""
files: dict[str, str] = {}
if not os.path.isdir(folder):
return files
for root, dirs, filenames in os.walk(folder):
# Mutate `dirs` in place that's how os.walk skips a subtree.
# Mutate `dirs` in place; that's how os.walk skips a subtree.
# Doing it here means we never even stat the children, so a
# 10k-file `.venv/` costs ~one stat (on the dir itself) instead
# of 10k.
@@ -275,7 +275,7 @@ def _walk_directory(folder: str) -> dict[str, str]:
# mis-parsed.
rel_path = os.path.relpath(full_path, folder).replace(os.sep, "/")
try:
# Stat first cheap, lets us skip giant files without
# Stat first; cheap, lets us skip giant files without
# opening + reading them.
size = os.path.getsize(full_path)
if size > _WALK_MAX_FILE_BYTES:
@@ -315,7 +315,7 @@ async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
content = _inject_data_into_html(content, input_json, result_json, backend_url_json)
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
# parent's ?token= query string, so rewrite the HTML to put the token
# back on every relative URL otherwise sub-resources 401.
# back on every relative URL; otherwise sub-resources 401.
content = _inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
@@ -387,7 +387,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
openswarm-ai/webapp-template snapshot (React + Vite + TS frontend
with an optional FastAPI backend) into the workspace, allocates a
free FRONTEND_PORT and writes it into both `.env` and
`.env.example`. BACKEND_PORT stays NONE the agent opts in with
`.env.example`. BACKEND_PORT stays NONE; the agent opts in with
`bash backend_init.sh`. Runtime spawn flips to `bash run.sh` and
the preview pane points at `http://localhost:{FRONTEND_PORT}/`.
`body.files` is ignored in this mode; the snapshot is the source
@@ -399,7 +399,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
# An explicit non-empty `files` payload means the caller has flat-mode
# content to write (a saved legacy Output being reseeded). Don't
# clobber that with the React template even if template_mode is the
# new default the migration helper has its own path for that.
# new default; the migration helper has its own path for that.
effective_mode = body.template_mode
if body.files:
effective_mode = "flat"
@@ -408,7 +408,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
# Idempotency guard: re-seeding an existing webapp_template
# workspace would clobber the agent's edits (the helper uses
# dirs_exist_ok=True + copytree). If `run.sh` already exists,
# the workspace was seeded on a previous visit skip the file
# the workspace was seeded on a previous visit; skip the file
# copy and only re-derive the frontend port from .env.
from backend.apps.outputs.runtime import _find_free_port, _read_env_value
already_seeded = os.path.exists(os.path.join(folder, "run.sh"))
@@ -421,7 +421,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
else:
frontend_port = _find_free_port()
seed_webapp_template_workspace(folder, frontend_port)
# SKILL.md still goes in workspace root agent reads it for
# SKILL.md still goes in workspace root; agent reads it for
# context. Live content (user-editable via Skills page) is
# injected into the system prompt regardless.
with open(os.path.join(folder, "SKILL.md"), "w") as f:
@@ -434,7 +434,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
# the Apps sidebar the moment the user kicks off generation.
# Previously the record only landed when the editor's autosave
# fired, which itself was gated on `files['index.html']` being
# non-empty (a flat-template invariant) meaning React+Vite
# non-empty (a flat-template invariant); meaning React+Vite
# apps that navigated-away mid-build had no way back. The record
# is a thin pointer (name + workspace_id); the workspace itself
# remains the source of truth for the code.
@@ -466,7 +466,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
"already_seeded": already_seeded,
}
# Legacy flat path unchanged.
# Legacy flat path; unchanged.
if body.files:
for rel_path, content in body.files.items():
full_path = os.path.normpath(os.path.join(folder, rel_path))
@@ -568,7 +568,7 @@ async def runtime_restart(workspace_id: str):
from backend.apps.outputs.runtime import manager as runtime_manager
# Restart only if something's attached; otherwise this is a no-op
# silently (a hard-reload click while the runtime was already torn
# down we'd rather not silently respawn an orphan).
# down; we'd rather not silently respawn an orphan).
rt = runtime_manager.get(workspace_id)
if rt:
await runtime_manager.restart(workspace_id, os.path.abspath(folder))
@@ -589,7 +589,7 @@ async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
folder_norm = os.path.normpath(folder)
full_path = os.path.normpath(os.path.join(folder, filepath))
# `startswith(folder_norm + os.sep)` (not just folder_norm) so a workspace
# `abc-123` can't be tricked into writing into a sibling `abc-1234-evil`
# `abc-123` can't be tricked into writing into a sibling `abc-1234-evil` ,
# prefix-string collision rather than path-component containment. Today's
# UUID-format ids make the collision unlikely in practice, but the check
# is one character and immunizes future id schemes.
@@ -795,7 +795,7 @@ async def execute_output(body: OutputExecute):
# HITL gate: collect warnings up front. If the caller hasn't opted
# in via force=True AND the code touches anything outside the safe
# allowlist, return the warnings + the code itself so the UI can
# show a preview dialog. No subprocess is spawned on this path
# show a preview dialog. No subprocess is spawned on this path ,
# zero-cost when warnings exist, identical-to-before when they
# don't.
if not body.force:
+37 -91
View File
@@ -1,16 +1,4 @@
"""Per-workspace persistent backend runtime.
Each App (workspace) has at most one long-running `backend.py` subprocess
managed by `AppRuntime`. Lifetime is reference-counted via the module-level
`manager` singleton: when the first ViewEditor / DashboardViewCard /
TerminalPanel attaches to a workspace, the process is spawned; when the
last detaches, it's terminated. Multiple subscribers share the same
process and the same in-memory log ring buffer.
This replaces the old one-shot `execute_backend_code` model for the
"backend serves real HTTP endpoints" use case. The one-shot path stays
around (see `executor.py`) for legacy `/api/outputs/execute` callers.
"""
"""Per-workspace persistent backend.py runtime; one AppRuntime per workspace, refcounted by manager singleton."""
import asyncio
import logging
@@ -25,70 +13,28 @@ from typing import Callable, Optional
logger = logging.getLogger(__name__)
# Recent log lines kept in memory per runtime. Lets a Terminal tab that
# opens mid-session replay the context that was already printed instead
# of seeing a blank pane. 2000 lines ≈ a few hundred KB at worst —
# bounded and predictable.
# 2000 lines per runtime; lets a Terminal tab opened mid-session replay context. ~few hundred KB at worst.
_LOG_BUFFER_LINES = 2000
# Seconds to wait after SIGTERM before escalating to SIGKILL. Most
# well-behaved Python servers shut down well under a second; this is the
# upper bound before we move on so a wedged process can't block a
# workspace tear-down forever.
# SIGTERM grace; well-behaved servers shut down under a second so 3s is enough.
_TERMINATE_GRACE_SECONDS = 3
# How long we'll wait for Vite (or whatever frontend server bash run.sh
# spawns) to bind on FRONTEND_PORT before giving up and reporting the
# frontend as "not ready." Covers cold-start `npm install` (~60-90s on
# typical hardware for the template's dependency set) plus the Vite
# bind itself. After this we keep the runtime running — the user can
# check the Terminal pane to see what went wrong — but stop blocking
# the preview pane on a port that may never come up.
# 180s covers npm install (60-90s on typical hardware) plus the Vite bind.
_FRONTEND_BIND_TIMEOUT_SECONDS = 180
# Drop from 0.5 → 0.08 because that 500ms window was ENTIRELY user-visible
# preview latency — after Vite actually binds we'd wait up to half a second
# before noticing and emitting runtime:status to the editor. 80ms TCP
# probes are cheap (async open_connection on localhost, no DNS, no
# handshake to a real upstream) and shave the perceived cold-start by
# roughly half a second. The asyncio.open_connection call has its own
# 500ms connect timeout for the failure case so a wedged listener won't
# turn this into a tight CPU loop.
# 80ms probe: dropping from 500ms was pure user-visible preview latency win; cheap on localhost.
_FRONTEND_BIND_POLL_INTERVAL = 0.08
# Process-wide mutex that serializes new-mode workspace boots so only
# ONE vite optimizeDeps run is in flight at a time. Acquired in
# `AppRuntime.start` (new-mode branch only) BEFORE the run.sh spawn,
# released by `_await_frontend_bind` the instant vite emits its
# "frontend ready" log line — or by the timeout / failure paths.
#
# Why a module-level asyncio.Lock and not part of AppRuntimeManager:
# the lock has to be acquired BEFORE the runtime is registered in
# manager.runtimes (which happens inside manager.attach's own
# `_lock`), and we can't hold both locks at once without inviting
# deadlock. Lifting to the module keeps the two locks fully
# independent — the manager lock guards the runtime dict, this one
# guards "is anyone currently mid-MUI-bundle?"
# Module-level lock so only ONE vite optimizeDeps runs at a time; must be acquired before manager._lock to avoid deadlock with manager.attach.
_vite_boot_lock = asyncio.Lock()
# Number of idle (zero-attachment) runtimes the manager keeps alive in
# its LRU before reaping the oldest. Trades memory for instant
# switch-back: clicking a previously-opened App reattaches to an
# already-running vite + uvicorn instead of paying the ~1-2s spawn
# cost. Bumped beyond 1 because the typical "App Builder" user keeps
# 2-3 in-progress apps and ping-pongs between them.
# Idle runtimes kept in LRU; trades memory for instant switch-back, beyond 1 because typical users ping-pong 2-3 apps.
_MAX_IDLE_RUNTIMES = 3
# Cap on recent error lines kept per workspace runtime. The agent only
# needs a snapshot of "what broke since my last write" — older errors
# get dropped. 50 is enough to catch a babel error message + its stack
# trace + a couple of related warnings without bloating the context.
# Cap on recent error lines the agent gets; 50 is enough for babel error + stack + a few warnings.
_RECENT_ERRORS_MAX = 50
# Regex that matches lines we want to surface back to the agent. Picks
# up the common JS/TS/Python build-error formats vite, babel, tsc, and
# uvicorn emit. Kept narrow on purpose so routine info logs and
# deprecation warnings don't pollute the agent's context.
# Narrow regex for build errors (vite, babel, tsc, uvicorn); keeps routine logs out of agent context.
import re as _re
_ERROR_PATTERNS = _re.compile(
r"(?:"
@@ -114,10 +60,10 @@ def _suspend_process_tree(proc: Optional[asyncio.subprocess.Process]) -> None:
PROCESS GROUP (negative PID) when the child is a session leader,
so vite + uvicorn + their npm/python subchildren all pause together.
No-op on Windows (SIGSTOP has no equivalent the `OpenProcessToken` +
No-op on Windows (SIGSTOP has no equivalent; the `OpenProcessToken` +
`NtSuspendProcess` route works but isn't worth the win32 surface
here; idle Windows runtimes just stay running, which is the current
behavior). Failures here are swallowed if the process already died
behavior). Failures here are swallowed; if the process already died
a stop signal is meaningless."""
if proc is None or os.name == "nt":
return
@@ -126,7 +72,7 @@ def _suspend_process_tree(proc: Optional[asyncio.subprocess.Process]) -> None:
return
os.kill(proc.pid, signal.SIGSTOP)
except (ProcessLookupError, PermissionError, OSError):
# Already-dead or out-of-permission both safe to ignore.
# Already-dead or out-of-permission; both safe to ignore.
pass
@@ -184,7 +130,7 @@ def _find_free_port() -> int:
def _is_new_mode(workspace_path: str) -> bool:
"""A workspace is "new-mode" (webapp-template scaffold) if it has a
`run.sh` at its root. Old-mode workspaces are flat `index.html`-only
apps that pre-date the template swap they're served by OpenSwarm's
apps that pre-date the template swap; they're served by OpenSwarm's
own `/api/outputs/workspace/{ws}/serve/...` FastAPI route and have an
optional `backend.py` we spawn directly.
@@ -211,7 +157,7 @@ def _read_env_value(env_path: str, key: str) -> Optional[str]:
if k.strip() != key:
continue
v = v.strip()
# Strip an inline `# comment`. Naive bash semantics are
# Strip an inline `# comment`. Naive; bash semantics are
# more permissive, but values we write don't contain `#`.
if "#" in v:
v = v.split("#", 1)[0].rstrip()
@@ -262,7 +208,7 @@ class AppRuntime:
self.process: Optional[asyncio.subprocess.Process] = None
self.log_buffer: deque[LogLine] = deque(maxlen=_LOG_BUFFER_LINES)
self._subscribers: set[LogSubscriber] = set()
# Recent build/runtime errors scraped from stderr drained by
# Recent build/runtime errors scraped from stderr; drained by
# the agent's post-tool hook after Write/Edit so the agent sees
# vite/babel/uvicorn errors in its next turn and can self-fix
# instead of leaving the user with a red iframe overlay.
@@ -314,7 +260,7 @@ class AppRuntime:
without waiting for the subprocess to print anything.
- **Old-mode** (no `run.sh`): spawn `python -u backend.py` if
present, with `PORT` env var. This is the legacy path
present, with `PORT` env var. This is the legacy path ,
unchanged so flat-index.html apps keep working.
Returns True if a process is running after this call. False is
@@ -344,7 +290,7 @@ class AppRuntime:
ok = await self._start_new_mode()
if not ok:
# Spawn failed before the bind-poll task was
# created release synchronously so we don't
# created; release synchronously so we don't
# wedge the next workspace.
_vite_boot_lock.release()
return ok
@@ -358,14 +304,14 @@ class AppRuntime:
fp_raw = _read_env_value(env_path, "FRONTEND_PORT")
bp_raw = _read_env_value(env_path, "BACKEND_PORT")
# FRONTEND_PORT is allocated by seed_workspace; should always be
# a number. If missing, log + fall back to a fresh allocation
# a number. If missing, log + fall back to a fresh allocation ,
# rare edge case (workspace seeded by an older OpenSwarm).
try:
self.frontend_port = int(fp_raw) if fp_raw else _find_free_port()
except ValueError:
self.frontend_port = _find_free_port()
# BACKEND_PORT may be the literal string "NONE" (frontend-only
# app the common case) or a number once `backend_init.sh` has
# app; the common case) or a number once `backend_init.sh` has
# run. Only populate self.port when there's a real backend.
if bp_raw and bp_raw != "NONE":
try:
@@ -407,7 +353,7 @@ class AppRuntime:
self.process = None
return False
backend_note = f" + backend on {self.port}" if self.port else ""
self._broadcast(LogLine("runtime", f"[runtime] bash run.sh started frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})"))
self._broadcast(LogLine("runtime", f"[runtime] bash run.sh started; frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})"))
self._stdout_task = asyncio.create_task(self._pipe_stream(self.process.stdout, "stdout"))
self._stderr_task = asyncio.create_task(self._pipe_stream(self.process.stderr, "stderr"))
self._wait_task = asyncio.create_task(self._await_exit())
@@ -425,7 +371,7 @@ class AppRuntime:
`frontend_url` property reads.
Also responsible for releasing the module-level `_vite_boot_lock`
every exit path (success, process death, hard timeout) MUST
; every exit path (success, process death, hard timeout) MUST
release exactly once so the next queued workspace can start its
own vite spawn. A try/finally on the lock guarantees that even
an exception in the poll body doesn't strand the lock holding."""
@@ -451,7 +397,7 @@ class AppRuntime:
port = self.frontend_port
deadline = asyncio.get_event_loop().time() + _FRONTEND_BIND_TIMEOUT_SECONDS
while asyncio.get_event_loop().time() < deadline:
# Stop polling if the process died pointless to keep
# Stop polling if the process died; pointless to keep
# checking a port nothing will bind.
if self.process is None or self.process.returncode is not None:
return
@@ -472,7 +418,7 @@ class AppRuntime:
f"[runtime] frontend ready at http://127.0.0.1:{port}/",
))
# Release the vite-boot mutex the INSTANT vite is
# ready the next queued workspace can start its
# ready; the next queued workspace can start its
# own bundle now even though we'll keep streaming
# logs for this one.
_release_boot_lock()
@@ -480,12 +426,12 @@ class AppRuntime:
except (OSError, asyncio.TimeoutError):
pass
await asyncio.sleep(_FRONTEND_BIND_POLL_INTERVAL)
# Timed out keep the runtime up (Terminal might show useful
# Timed out; keep the runtime up (Terminal might show useful
# errors) but surface why the preview never appeared.
self._broadcast(LogLine(
"runtime",
f"[runtime] frontend did NOT bind on port {port} after "
f"{_FRONTEND_BIND_TIMEOUT_SECONDS}s check the Terminal "
f"{_FRONTEND_BIND_TIMEOUT_SECONDS}s; check the Terminal "
f"for npm/vite errors.",
))
finally:
@@ -502,7 +448,7 @@ class AppRuntime:
self.port = _find_free_port()
env = self._spawn_env_base()
env["PORT"] = str(self.port)
env["BACKEND_PORT"] = str(self.port) # alias both common names work
env["BACKEND_PORT"] = str(self.port) # alias; both common names work
try:
# -u forces unbuffered stdout/stderr so the Terminal pane
# sees lines in real time, not whenever Python decides to
@@ -537,7 +483,7 @@ class AppRuntime:
async with self._lock:
if not self.process or self.process.returncode is not None:
# Still cancel the bind poller in case stop() races a
# never-launched runtime defensive no-op otherwise.
# never-launched runtime; defensive no-op otherwise.
if self._frontend_ready_task and not self._frontend_ready_task.done():
self._frontend_ready_task.cancel()
return
@@ -578,7 +524,7 @@ class AppRuntime:
def _broadcast(self, line: LogLine) -> None:
self.log_buffer.append(line)
# Snapshot subscribers they can self-remove during dispatch.
# Snapshot subscribers; they can self-remove during dispatch.
for cb in list(self._subscribers):
try:
cb(line)
@@ -587,7 +533,7 @@ class AppRuntime:
def _maybe_capture_error(self, text: str) -> None:
"""If a stderr/stdout line matches a known build-error pattern,
record it for the next agent-tool drain. Tests every line
record it for the next agent-tool drain. Tests every line ,
cheap (single regex search) and only the matching ones land in
the buffer."""
if _ERROR_PATTERNS.search(text):
@@ -622,7 +568,7 @@ class AppRuntimeManager:
Reference-counts attachments so we don't kill a backend when one
Terminal closes while another is still subscribed. First attach
spawns; final detach moves the runtime into an LRU idle pool
instead of stopping it immediately so re-clicking a recent App
instead of stopping it immediately; so re-clicking a recent App
is instant. The oldest runtime gets reaped once the pool exceeds
_MAX_IDLE_RUNTIMES."""
@@ -638,14 +584,14 @@ class AppRuntimeManager:
async def attach(self, workspace_id: str, workspace_path: str) -> AppRuntime:
revived = False
# Defined here so every code path below leaves it bound the
# Defined here so every code path below leaves it bound; the
# revive-idle branch used to skip the assignment, leaving the
# post-lock `if dead is not None:` check throwing UnboundLocalError.
dead: Optional[AppRuntime] = None
async with self._lock:
rt = self.runtimes.get(workspace_id)
if rt is None:
# Maybe the runtime is sitting idle in the LRU revive
# Maybe the runtime is sitting idle in the LRU; revive
# it without paying the spawn cost again.
idle_rt = self._idle_lru.pop(workspace_id, None)
if idle_rt is not None and idle_rt.running:
@@ -658,7 +604,7 @@ class AppRuntimeManager:
_resume_process_tree(rt.process)
else:
if idle_rt is not None:
# Stale idle entry process died while idling.
# Stale idle entry; process died while idling.
# Drop and spawn a fresh one below; old one
# gets stopped outside the lock.
dead = idle_rt
@@ -667,7 +613,7 @@ class AppRuntimeManager:
else:
# Workspace paths shouldn't change for a given id, but if
# somehow they did (e.g. the user moved the workspace
# folder), trust the latest caller they have the
# folder), trust the latest caller; they have the
# current truth.
rt.workspace_path = workspace_path
self._attached[workspace_id] = self._attached.get(workspace_id, 0) + 1
@@ -694,7 +640,7 @@ class AppRuntimeManager:
if rt is None:
return
# If the process is already dead, no point keeping it
# around just clean up. Otherwise move to the LRU AND
# around; just clean up. Otherwise move to the LRU AND
# SIGSTOP the process tree so it consumes 0% CPU while
# idle. The matching SIGCONT lives in attach() above.
if not rt.running:
@@ -736,7 +682,7 @@ class AppRuntimeManager:
"""If `file_path` falls under one of the live workspace
runtimes' workspace_path, drain that workspace's recent
build/runtime errors. Returns [] if no workspace owns the path
or no errors are queued caller can treat empty as 'all clear'.
or no errors are queued; caller can treat empty as 'all clear'.
Used by agent_manager's post-tool hook so the agent sees vite /
babel / uvicorn errors right after a Write/Edit completes."""
if not file_path:
@@ -745,7 +691,7 @@ class AppRuntimeManager:
abs_path = os.path.abspath(file_path)
except Exception:
return []
# Walk both active and idle runtimes the user might have
# Walk both active and idle runtimes; the user might have
# navigated away from the workspace mid-build, but the agent
# could still be editing files; the LRU keeps the runtime alive
# for ~3 idle slots.
+25 -25
View File
@@ -27,7 +27,7 @@ SWARM_DEBUG_SKILL_SOURCE_PATH = os.path.join(os.path.dirname(__file__), "swarm_d
# scripts/fetch-webapp-template.sh for the snapshot fetch + patches.
WEBAPP_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "webapp_template")
# Bundled default used as the read-once fallback if the user-editable
# Bundled default; used as the read-once fallback if the user-editable
# copy at ~/.claude/skills/app_builder_skill.md has been removed despite
# the built-in flag (defensive; shouldn't happen in normal use).
with open(APP_BUILDER_SKILL_SOURCE_PATH, encoding="utf-8") as _f:
@@ -38,7 +38,7 @@ def load_app_builder_skill() -> str:
"""Return the live App Builder skill content. Prefers the
user-editable copy at ~/.claude/skills/app_builder_skill.md (so a
user's edit on the Skills page takes effect on the very next App
Builder agent turn no restart, no copy-on-edit dance). Falls back
Builder agent turn; no restart, no copy-on-edit dance). Falls back
to the bundled default if the user file is somehow gone."""
user_path = os.path.expanduser("~/.claude/skills/app_builder_skill.md")
if os.path.exists(user_path):
@@ -50,7 +50,7 @@ def load_app_builder_skill() -> str:
return APP_BUILDER_SKILL_DEFAULT
# Backward-compat alias. Older callers import VIEW_BUILDER_SKILL directly
# Backward-compat alias. Older callers import VIEW_BUILDER_SKILL directly ,
# point them at the same content as the user-editable version so a "frozen
# at import" stale copy can't drift from what the skills page shows.
VIEW_BUILDER_SKILL = APP_BUILDER_SKILL_DEFAULT
@@ -127,7 +127,7 @@ VIEW_TEMPLATE_FILES = {
# ---------------------------------------------------------------------------
def _ignore_backend(src: str, names: list[str]) -> list[str]:
"""copytree filter when copying the template root, drop only the
"""copytree filter; when copying the template root, drop only the
top-level `backend/` directory. Subdirectories named `backend` deeper
in the tree (none today, but defensively scoped) are unaffected."""
if os.path.abspath(src) == os.path.abspath(WEBAPP_TEMPLATE_DIR):
@@ -142,13 +142,13 @@ _TEMPLATE_BACKEND_PATH = os.path.abspath(os.path.join(WEBAPP_TEMPLATE_DIR, "back
# ---------------------------------------------------------------------------
# Shared node_modules cache every new webapp-template workspace symlinks
# Shared node_modules cache; every new webapp-template workspace symlinks
# its frontend/node_modules to a single warm directory. First-app create
# pays the ~22s npm-install cost once; every subsequent app is instant
# (just a symlink + vite startup, ~1s).
#
# Cache directory is keyed by a sha of the template's package.json, so a
# template dep bump invalidates the cache automatically old caches sit
# template dep bump invalidates the cache automatically; old caches sit
# until the user clears ~/.openswarm/cache.
# ---------------------------------------------------------------------------
@@ -159,7 +159,7 @@ _warm_cache_thread: threading.Thread | None = None
# Pre-built node_modules archive bundled with packaged releases. Generated
# by `scripts/build-template-archive.sh` and shipped at this path inside
# the app's resources. When present (and tagged with the current
# package.json sha), extract instead of running npm decompression is
# package.json sha), extract instead of running npm; decompression is
# ~3 s vs ~22 s for the live install. Stale archives (package.json bumped
# but archive not rebuilt) are silently ignored, so the live-install
# fallback always wins on correctness.
@@ -191,7 +191,7 @@ def _try_extract_bundled_archive(cache_dir: str, digest: str) -> bool:
)
os.makedirs(cache_dir, exist_ok=True)
# Archive root is `node_modules/`; extracting into cache_dir places
# it at the expected path. tarfile uses zlib internally for .gz
# it at the expected path. tarfile uses zlib internally for .gz ,
# no extra dep needed.
with tarfile.open(archive_path, "r:gz") as tar:
tar.extractall(cache_dir)
@@ -214,7 +214,7 @@ def _try_extract_bundled_archive(cache_dir: str, digest: str) -> bool:
def _warm_cache_digest() -> str:
"""Sha of the template's frontend/package.json used as the cache
"""Sha of the template's frontend/package.json; used as the cache
key + the bundled-archive filename suffix so a package.json bump
invalidates both at once."""
pkg_path = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json")
@@ -237,7 +237,7 @@ def _warm_cache_dir() -> str:
def _ensure_warm_cache() -> str | None:
"""Populate the warm-cache node_modules if missing. Returns the
absolute path to the populated `node_modules` directory, or None on
failure. Thread-safe concurrent callers block on a single install
failure. Thread-safe; concurrent callers block on a single install
instead of racing. Idempotent and fast after the first call."""
cache_dir = _warm_cache_dir()
cache_modules = os.path.join(cache_dir, "node_modules")
@@ -259,7 +259,7 @@ def _ensure_warm_cache() -> str | None:
os.makedirs(cache_dir, exist_ok=True)
# Copy package.json + lockfile (if it exists) into the cache
# dir so npm has something to install from. We don't write
# back to the template the lockfile generated here stays
# back to the template; the lockfile generated here stays
# local to the cache.
tmpl_pkg = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json")
tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json")
@@ -269,7 +269,7 @@ def _ensure_warm_cache() -> str | None:
shutil.copyfile(tmpl_lock, os.path.join(cache_dir, "package-lock.json"))
cmd = ["npm", "ci", *base_flags]
else:
# No lockfile yet `npm install` resolves the tree and
# No lockfile yet; `npm install` resolves the tree and
# writes one into the cache dir for future use.
cmd = ["npm", "install", *base_flags]
logger.info("webapp-template: warming node_modules cache at %s", cache_dir)
@@ -291,7 +291,7 @@ def _ensure_warm_cache() -> str | None:
def _link_node_modules(workspace_dir: str) -> None:
"""After copytree, point the workspace's frontend/node_modules at
the warm-cache directory. Safe fallback if the cache isn't ready,
the warm-cache directory. Safe fallback; if the cache isn't ready,
the workspace's run.sh will fall through to its own install path."""
cache_modules = _ensure_warm_cache()
if not cache_modules:
@@ -309,9 +309,9 @@ def _link_node_modules(workspace_dir: str) -> None:
return
elif os.path.isdir(target):
# If the dir is EMPTY (left over from copytree of the template's
# placeholder node_modules `.gitkeep`-style scenarios) nuke it
# placeholder node_modules; `.gitkeep`-style scenarios) nuke it
# so we can symlink to the warm cache. A non-empty directory is
# treated as a real npm install respect it and bail.
# treated as a real npm install; respect it and bail.
try:
has_content = any(True for _ in os.scandir(target))
except OSError:
@@ -331,7 +331,7 @@ def _link_node_modules(workspace_dir: str) -> None:
# ---------------------------------------------------------------------------
# Shared Python venv cache same pattern as the node_modules cache, but
# Shared Python venv cache; same pattern as the node_modules cache, but
# for the workspace backend's FastAPI + transitive deps. Eliminates the
# ~25s `python -m venv` + `pip install -e .` that backend_init.sh
# otherwise pays per workspace.
@@ -358,7 +358,7 @@ def _warm_venv_dir() -> str:
def _ensure_warm_python_venv() -> str | None:
"""Populate the warm-cache backend venv if missing. Returns the
absolute path to the populated `.venv` directory, or None on
failure. Thread-safe and idempotent fast return after first call."""
failure. Thread-safe and idempotent; fast return after first call."""
cache_dir = _warm_venv_dir()
venv_dir = os.path.join(cache_dir, ".venv")
sentinel = os.path.join(cache_dir, ".populated")
@@ -374,7 +374,7 @@ def _ensure_warm_python_venv() -> str | None:
# Pick the same python the workspace's run.sh would have
# picked, so the venv's binary is compatible. Includes
# bare `python` as the last fallback for Windows, where
# there's no `python3` symlink the installer ships just
# there's no `python3` symlink; the installer ships just
# `python.exe`. On macOS/Linux the versioned candidates
# match first so we don't accidentally pick a system
# Python 2.x via the bare name.
@@ -405,7 +405,7 @@ def _ensure_warm_python_venv() -> str | None:
return None
# Install the template's dependencies (fastapi[standard],
# typeguard, transitives) NOT the workspace's own backend,
# typeguard, transitives); NOT the workspace's own backend,
# which gets editable-installed per-workspace by run.sh after
# the cache copy. The venv layout differs by platform:
# POSIX puts executables in `bin/`, Windows in `Scripts/`,
@@ -461,7 +461,7 @@ def warm_cache_in_background() -> None:
_warm_cache_thread.start()
# Trigger pre-warm on module import backend startup hits this and the
# Trigger pre-warm on module import; backend startup hits this and the
# installs run in parallel with the rest of the boot. By the time the
# user creates their first app, node_modules + the backend venv are
# usually ready.
@@ -495,10 +495,10 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
1. Copy `.env.example` `.env` verbatim (preserves the upstream
defaults `FRONTEND_PORT=4949` and `BACKEND_PORT=NONE`).
2. Sed both `.env` and `.env.example` to set `FRONTEND_PORT=<port>`.
BACKEND_PORT stays NONE in both (per spec the agent flips it
BACKEND_PORT stays NONE in both (per spec; the agent flips it
via backend_init.sh when it needs a backend).
3. Append two install-specific paths to `.env` ONLY (NOT
`.env.example` these are absolute paths on the current
`.env.example`; these are absolute paths on the current
machine, not template defaults):
OPENSWARM_TEMPLATE_BACKEND_PATH=<abs path to master template's backend/>
OPENSWARM_DEBUGGER_PATH=<abs path to OpenSwarm's debugger/ package>
@@ -506,7 +506,7 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
the template's `backend/run.sh` to install our local debugger
before `pip install -e .`.
Idempotent within reason re-running over an existing workspace
Idempotent within reason; re-running over an existing workspace
overwrites template files and re-asserts the env values.
"""
os.makedirs(workspace_dir, exist_ok=True)
@@ -528,10 +528,10 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
_patch_env_port(env_path, "FRONTEND_PORT", str(frontend_port))
_patch_env_port(env_example_path, "FRONTEND_PORT", str(frontend_port))
# Install-specific paths .env only.
# Install-specific paths; .env only.
_patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", _TEMPLATE_BACKEND_PATH)
_patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", _DEBUGGER_PATH)
# Backend-venv warm-cache path backend_init.sh checks this for a
# Backend-venv warm-cache path; backend_init.sh checks this for a
# pre-populated `.venv/` to cp -aR into the workspace instead of
# paying the ~25s venv-create + pip-install cost. Written even if
# the cache isn't ready yet; backend_init.sh re-checks at run time.
@@ -3,7 +3,7 @@
#
# Idempotent. The workspace is seeded frontend-only (no backend/ dir,
# BACKEND_PORT=NONE). Run this script when your App needs server-side
# code it copies the master template's backend/ into the workspace
# code; it copies the master template's backend/ into the workspace
# and flips BACKEND_PORT in both .env files to a free port.
#
# After running this, hard-reload the preview (right-click the reload
@@ -15,7 +15,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found at $HERE — is this the workspace root?" >&2
echo "ERROR: .env not found at $HERE. Is this the workspace root?" >&2
exit 1
fi
@@ -26,12 +26,12 @@ source .env
set +a
if [[ "${BACKEND_PORT:-NONE}" != "NONE" ]]; then
echo "Backend already enabled on port $BACKEND_PORT nothing to do." >&2
echo "Backend already enabled on port $BACKEND_PORT, nothing to do." >&2
exit 0
fi
if [[ -d ./backend ]]; then
echo "ERROR: ./backend/ already exists but BACKEND_PORT=NONE your" >&2
echo "ERROR: ./backend/ already exists but BACKEND_PORT=NONE; your" >&2
echo " workspace is in an inconsistent state. Either delete" >&2
echo " ./backend/ and re-run, or set BACKEND_PORT manually." >&2
exit 1
@@ -56,7 +56,7 @@ echo "Copying backend/ from $OPENSWARM_TEMPLATE_BACKEND_PATH..."
cp -R "$OPENSWARM_TEMPLATE_BACKEND_PATH" ./backend
chmod +x ./backend/run.sh
# Reuse the warm-cache backend venv if available this skips the
# Reuse the warm-cache backend venv if available; this skips the
# ~5s venv-create + ~20s pip-install in the workspace's backend/run.sh.
# The cache holds FastAPI + transitives pre-installed; the workspace's
# own editable install (`pip install -e .`) still runs once on first
+5 -5
View File
@@ -3,8 +3,8 @@
When the desktop is offline (laptop closed, no internet, cloud unreachable),
the service-sync layer can't reach `api.openswarm.com`. Rather than drop
data on the floor, we spool submissions to a small SQLite file and replay
them on the next online tick. The spool is bounded when full, the oldest
entries are dropped so it can never balloon to a problem.
them on the next online tick. The spool is bounded; when full, the oldest
entries are dropped; so it can never balloon to a problem.
Single file, single table, single thread guarded by a sqlite3 connection's
implicit lock. No concurrency model beyond "don't write from two processes
@@ -24,7 +24,7 @@ from typing import Iterator, Optional
logger = logging.getLogger(__name__)
# Cap the spool at 50 MB on disk. SQLite's overhead means the actual ceiling
# on retained payloads is somewhat smaller, which is fine this is a
# on retained payloads is somewhat smaller, which is fine; this is a
# best-effort cushion, not a guaranteed retention window.
_MAX_BYTES = 50 * 1024 * 1024
@@ -63,7 +63,7 @@ def enqueue(spool_path: str, kind: str, payload: dict, *, now: float) -> None:
"INSERT INTO spool (kind, payload, created_at) VALUES (?, ?, ?)",
(kind, body, now),
)
# Cheap size check only run trim when stat says we're over.
# Cheap size check; only run trim when stat says we're over.
try:
size = os.path.getsize(spool_path)
except OSError:
@@ -108,7 +108,7 @@ def drain(spool_path: str, batch_size: int = 50) -> list[tuple[int, str, dict]]:
try:
out.append((rid, kind, json.loads(body)))
except json.JSONDecodeError:
# Corrupt row discard so it doesn't block draining behind it.
# Corrupt row; discard so it doesn't block draining behind it.
with _lock, _conn(spool_path) as c:
c.execute("DELETE FROM spool WHERE id = ?", (rid,))
logger.warning("Dropped corrupt spool row id=%s", rid)
+8 -8
View File
@@ -4,7 +4,7 @@ Single public surface: `submit(kind, payload)`. The desktop hands off
opaque payload dicts; the cloud at api.openswarm.com is responsible for
parsing and routing them. The desktop has no schema knowledge.
Three `kind` values are accepted they're the routing primitive the
Three `kind` values are accepted; they're the routing primitive the
cloud needs to send the payload to the right backend handler. The shape
of `payload` is opaque from the desktop's perspective; the cloud knows
how to read it.
@@ -61,7 +61,7 @@ def _spool_path() -> str:
def set_test_sink(fn: Optional[Any]) -> None:
"""Test seam receives every submission instead of the network."""
"""Test seam; receives every submission instead of the network."""
global _test_sink
_test_sink = fn
@@ -92,7 +92,7 @@ def _get_user_id() -> Optional[str]:
from backend.apps.settings.settings import load_settings
s = load_settings()
# Prefer the cloud-issued user_id (UUID) if the user has signed in
# via Google OAuth, magic link, or Stripe checkout that's the
# via Google OAuth, magic link, or Stripe checkout; that's the
# authoritative identity. Falls back to user_email for installs
# that haven't completed sign-in yet (so existing onboarding-only
# installs don't lose their Person history during the v1.0.29
@@ -164,7 +164,7 @@ def _envelope() -> dict:
except Exception:
pass
# Locale: BCP 47 string ("en-US", "es-ES", etc.) injected by Electron via
# app.getLocale() see electron/main.js. We don't fall back to Python's
# app.getLocale(); see electron/main.js. We don't fall back to Python's
# locale.getdefaultlocale() because that's deprecated, often empty, and
# returns inconsistent OS-specific values across macOS/Windows/Linux.
try:
@@ -265,7 +265,7 @@ def _log(kind: str, payload: dict) -> None:
def sync(data: dict | None = None) -> None:
"""Sync operational state to the cloud. Single entry point.
Accepts any dict the cloud determines what it is from the shape.
Accepts any dict; the cloud determines what it is from the shape.
The desktop has no knowledge of event types, schemas, or routing.
Each call carries:
@@ -295,12 +295,12 @@ def sync(data: dict | None = None) -> None:
_schedule(_post_or_spool(_DEFAULT_SYNC_PATH, body, "s"))
# Internal routing the cloud has one endpoint for everything.
# Internal routing; the cloud has one endpoint for everything.
_DEFAULT_SYNC_PATH = "/api/service/sync"
def submit(kind: str, payload: dict) -> None:
"""Legacy shim routes through sync(). Kept for back-compat during
"""Legacy shim; routes through sync(). Kept for back-compat during
migration. New code should call sync() directly."""
sync(payload)
@@ -379,7 +379,7 @@ def record(
session_id: Optional[str] = None,
dashboard_id: Optional[str] = None,
) -> None:
"""Legacy collector.record() shim splits dotted name into surface/action."""
"""Legacy collector.record() shim; splits dotted name into surface/action."""
if "." in event_type:
surface, action = event_type.split(".", 1)
else:
+1 -5
View File
@@ -1,5 +1 @@
"""(Reserved for future use; intentionally empty.)
The service-sync layer ships opaque payload dicts through `submit()`
no Pydantic shape exposed in the public repo.
"""
"""Reserved; service-sync ships opaque payload dicts via submit(), no Pydantic shape exposed."""
+1 -7
View File
@@ -1,10 +1,4 @@
"""Fixed-size event log for operational diagnostics.
Maintains a rolling window of the last N app events so support
diagnostics can include context about recent activity. Used by
the error report builder to attach "what just happened" when
something goes wrong.
"""
"""Fixed-size rolling event log for support diagnostics."""
from __future__ import annotations
+9 -9
View File
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
def _read_app_version() -> str:
# Preferred: Electron's main process injects this when spawning the
# backend (see electron/main.js OPENSWARM_APP_VERSION). Always reliable
# backend (see electron/main.js; OPENSWARM_APP_VERSION). Always reliable
# in packaged builds because it comes from app.getVersion() rather than
# path-based file resolution.
env_v = os.environ.get("OPENSWARM_APP_VERSION", "").strip()
@@ -42,7 +42,7 @@ def _read_app_version() -> str:
# Fallback: read electron/package.json via relative path. Works in
# `bash run.sh` dev mode where the repo layout is intact, but FAILS in
# packaged dmg/exe builds because electron/package.json isn't shipped
# into Resources/ which made every shipped install report
# into Resources/; which made every shipped install report
# app_version="unknown" pre-fix. Kept for backward compatibility with
# dev runs and as a safety net if the env var is ever unset.
try:
@@ -120,7 +120,7 @@ async def _pulse_loop():
if _pulse_count >= _pulse_batch_size:
try:
from backend.apps.agents.agent_manager import agent_manager
# Compact field names the wire stays small and the cloud
# Compact field names; the wire stays small and the cloud
# is the only place that knows what each key means.
svc.sync({
"a": len(agent_manager.sessions), # active sessions
@@ -412,26 +412,26 @@ async def service_status():
async def post_submit(body=Body(...)):
"""Accepts three body shapes for backward compatibility:
1. Frontend `report()` shape flat `{s, a, p, submission_id, t}`.
1. Frontend `report()` shape; flat `{s, a, p, submission_id, t}`.
This is what `frontend/src/shared/serviceClient.ts:report()` sends
on every UI interaction. Pass through unchanged so the cloud sees
it as a frontend.event.
2. Legacy `{kind, payload}` shape used by older callers that wrapped
2. Legacy `{kind, payload}` shape; used by older callers that wrapped
the payload in a kind+payload envelope before submitting. Unwrap
and forward the payload.
3. Batched array frontend collects up to 1s of events and sends them
3. Batched array; frontend collects up to 1s of events and sends them
as a single JSON array to cut N POSTs/sec down to 1. Each item is
processed exactly as if it had arrived as its own request.
Pre-fix this endpoint required shape #2 and silently rejected shape #1
with a 200 + `{ok:false}`, so every UI event from `report()` was
dropped `frontend.event` count was 0 in production analytics.
dropped; `frontend.event` count was 0 in production analytics.
"""
# Shape 3: batched array. Recurse per-item so single-item handling
# logic stays in one place. Returns a single ok regardless of
# individual item shape analytics calls aren't transactional.
# individual item shape; analytics calls aren't transactional.
if isinstance(body, list):
for item in body:
if isinstance(item, dict):
@@ -446,7 +446,7 @@ async def post_submit(body=Body(...)):
return {"ok": True}
if not isinstance(body, dict):
return {"ok": False, "error": "JSON object or array required"}
# Shape 1: frontend `report()` flat {s, a, p, ...}
# Shape 1: frontend `report()`; flat {s, a, p, ...}
if any(k in body for k in ("s", "a", "p")):
svc.sync(body)
return {"ok": True}
+12 -38
View File
@@ -1,8 +1,4 @@
"""Centralized credential resolution for LLM API calls.
Supports multiple providers: Anthropic (native), OpenAI, Gemini,
OpenRouter, and user-configured custom providers.
"""
"""Resolve LLM credentials for the configured provider."""
from __future__ import annotations
@@ -26,18 +22,14 @@ def _check_9router() -> bool:
def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> None:
"""Raise ValueError if credentials are missing for the given provider.
Allows through if 9Router is running as a fallback.
Handles both display names ('Anthropic') and lowercase ('anthropic').
"""
"""Raise ValueError if the provider has no usable credentials."""
p = provider.lower().strip()
# 9Router-backed providers don't need traditional credentials
# 9Router handles its own credentials.
if p == "9router":
return
# If 9Router is running, all providers are accessible
# 9Router proxies every provider, so if it's up we don't need keys here.
if _check_9router():
return
@@ -62,21 +54,20 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
return
raise ValueError("OpenRouter API key not configured. Set it in Settings.")
elif p in ("xai", "meta", "deepseek", "mistral", "qwen", "cohere"):
# These route through OpenRouter — need either OpenRouter key or 9Router
# These providers route through OpenRouter, so its key is required.
if getattr(settings, "openrouter_api_key", None):
return
raise ValueError(f"{provider} requires an OpenRouter API key, or connect a subscription via 9Router.")
else:
# Custom provider — check if it exists in custom_providers
for cp in getattr(settings, "custom_providers", []):
if cp.name.lower() == p:
return
# Unknown provider — allow through (create_provider will handle the error)
# Let create_provider raise for unknown providers; not our job here.
return
def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
"""Return credential dict for a specific provider."""
"""Return the credential dict for the given provider."""
p = provider.lower().strip()
validate_credentials(settings, provider)
@@ -97,13 +88,9 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
if p == "openrouter":
return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
# Custom provider
for cp in getattr(settings, "custom_providers", []):
if cp.name.lower() == p:
# Substitute a placeholder when the user left api_key blank
# — local OpenAI-compatible servers (LM Studio, Ollama, etc.)
# ignore the Bearer header but downstream callers may insist
# on non-empty values.
# Local OpenAI-compatible servers (LM Studio, Ollama) ignore the key; placeholder keeps downstream callers happy.
key = (cp.api_key or "").strip() or "no-auth-required"
return {"api_key": key, "base_url": cp.base_url}
@@ -111,10 +98,7 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""Return a configured AsyncAnthropic client based on connection mode.
Priority: managed mode 9Router subscription API key
"""
"""Return an AsyncAnthropic client for the user's current connection mode."""
import anthropic
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
@@ -124,11 +108,11 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
base_url=proxy_url,
)
# Prefer API key when set
# Prefer the user's own API key when present.
if settings.anthropic_api_key:
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# Fall back to 9Router subscription (free for users with Claude/ChatGPT/Gemini subscriptions)
# Fall back to 9Router (free for users with Claude/ChatGPT/Gemini subscriptions).
if _check_9router():
return anthropic.AsyncAnthropic(
api_key="9router",
@@ -139,17 +123,7 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
def get_anthropic_client_for_model(settings: AppSettings, api_model: str) -> anthropic.AsyncAnthropic:
"""Return a client configured for the given resolved model id.
When api_model carries a 9Router prefix (cc/, cx/, gc/, cp-), the client
targets 9Router directly even if connection_mode is openswarm-pro. This
is what lets pinned-route models like "sonnet-cc" actually reach the
user's own subscription instead of getting sent through the managed proxy
with an unrecognizable model id. cp- is the prefix we use when registering
user-configured custom OpenAI-compatible providers in 9Router.
Otherwise delegates to get_anthropic_client() for the default mode-driven
routing.
"""
"""Route 9Router-prefixed models (cc/, cx/, gc/, cp-) straight to 9Router so user subscriptions reach their own accounts."""
import anthropic
if isinstance(api_model, str) and (
api_model.startswith(("cc/", "cx/", "gc/")) or api_model.startswith("cp-")
+18 -40
View File
@@ -4,27 +4,27 @@ from typing import Optional, Any, Literal
DEFAULT_SYSTEM_PROMPT = (
"You are a personal AI assistant running inside OpenSwarm.\n\n"
"## Core Behavior\n"
"Act, don't ask. When a tool can accomplish the task, call it immediately "
"Act, don't ask. When a tool can accomplish the task, call it immediately; "
"do not describe what you would do, do not ask for confirmation, just execute. "
"The user expects results, not plans.\n"
"If ANY available tool is relevant to the user's request, use it. Never respond "
'with "I can do X for you" or "Would you like me to..." just do it. '
'with "I can do X for you" or "Would you like me to..."; just do it. '
"A tool call is always better than a text explanation of what the tool would do.\n"
"For multi-step tasks, chain tool calls in sequence don't stop after one step "
"For multi-step tasks, chain tool calls in sequence; don't stop after one step "
"to ask if you should continue. Complete the entire task, then report the results.\n"
"Be adaptable. If one approach fails, try a different tool or strategy instead of "
"giving up or repeating the same action. Always stay focused on what the user "
"actually wants to accomplish their intent matters more than the specific method.\n\n"
"actually wants to accomplish; their intent matters more than the specific method.\n\n"
"## Tool Priority\n"
"1. Connected MCP tools fastest and most reliable. Use ToolSearch to discover "
"1. Connected MCP tools; fastest and most reliable. Use ToolSearch to discover "
"what integrations are available if you're unsure.\n"
"2. WebSearch / WebFetch for general web lookups when no MCP tool fits.\n"
"3. BrowserAgent last resort, only for visual interaction with websites, "
"2. WebSearch / WebFetch; for general web lookups when no MCP tool fits.\n"
"3. BrowserAgent; last resort, only for visual interaction with websites, "
"filling forms, or tasks no other tool can handle.\n\n"
"## Style\n"
"Do not narrate routine tool calls just call the tool.\n"
"Do not narrate routine tool calls; just call the tool.\n"
"After tool calls complete, present the results directly. Do not recap which "
"tools you called or why the user can see tool calls in the UI.\n"
"tools you called or why; the user can see tool calls in the UI.\n"
"Keep responses brief and direct. Use plain language.\n"
"If you genuinely need clarification on something ambiguous, use the "
"AskUserQuestion tool. Never ask questions inline in plain text.\n"
@@ -40,60 +40,38 @@ class AppSettings(BaseModel):
default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
zoom_sensitivity: float = 50.0
theme: str = "dark"
# App Builder workspaces seed a React template that ships with its own
# theme toggle ("Light" / "Dark" at the bottom of the sidebar). By
# default the template should follow the user's OS appearance; once
# the user explicitly toggles it inside any one app the override
# persists across every subsequently-built app via this field
# (the template fetches /api/settings on mount and PUTs back here on
# toggle, so the preference is shared even though each app runs from
# its own vite port / localStorage origin).
# null = follow system / no override; 'light' or 'dark' = sticky.
# Shared across App Builder workspaces (each runs its own vite port / localStorage origin); null = follow system.
app_template_theme_override: Optional[Literal["light", "dark"]] = None
new_agent_shortcut: str = "Meta+l"
anthropic_api_key: Optional[str] = None
browser_homepage: str = "https://www.google.com"
# Multi-provider API keys
openai_api_key: Optional[str] = None
google_api_key: Optional[str] = None
openrouter_api_key: Optional[str] = None
custom_providers: list["CustomProvider"] = Field(default_factory=list)
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
# Subscription tokens (from CLI tools — alternative to API keys)
claude_subscription_token: Optional[str] = None
openai_subscription_token: Optional[str] = None
gemini_subscription_token: Optional[str] = None
# User profile (collected during onboarding)
user_name: Optional[str] = None
user_email: Optional[str] = None
user_use_case: Optional[str] = None
user_referral_source: Optional[str] = None
# Per-MCP dismissal map for the preflight suggestion modal. Keyed by
# the curated ToolDefinition.name (e.g. "Google Workspace"); value is
# an ISO timestamp of dismissal. Used by mcp_preflight._build_available_shortlist
# to suppress suggestions the user has explicitly waved off.
# Suppresses preflight suggestion modal entries the user dismissed; keyed by ToolDefinition.name, value ISO timestamp.
dismissed_mcp_suggestions: dict[str, str] = Field(default_factory=dict)
# Analytics: opted in by default, user can toggle off
analytics_opt_in: bool = True
installation_id: Optional[str] = None
first_opened_at: Optional[str] = None # ISO timestamp of first app open
# OpenSwarm Pro subscription
connection_mode: str = "own_key" # "own_key" | "openswarm-pro"
first_opened_at: Optional[str] = None
connection_mode: str = "own_key"
openswarm_bearer_token: Optional[str] = None
openswarm_proxy_url: Optional[str] = None # default resolved in credentials.py
openswarm_subscription_plan: Optional[str] = None # "hobby"|"pro"|"pro_plus"|"ultra"
openswarm_subscription_expires: Optional[str] = None # ISO 8601
openswarm_usage_cached: Optional[dict] = None # {count, limit, window_end_at}
# Identity (v1.0.29+). Populated after a successful sign-in via the cloud's
# /api/auth/signin-activate endpoint (Google OAuth or email magic link).
# Stripe checkout also populates these because the cloud's bearer-mint
# always returns user info. Distinct from user_email above which was
# historically a self-reported onboarding field — the values agree once
# sign-in completes (server-validated wins).
openswarm_proxy_url: Optional[str] = None
openswarm_subscription_plan: Optional[str] = None
openswarm_subscription_expires: Optional[str] = None
openswarm_usage_cached: Optional[dict] = None
# Server-validated identity from /api/auth/signin-activate; user_email above is the self-reported onboarding value.
user_id: Optional[str] = None
signin_method: Optional[Literal["google", "stripe", "email"]] = None
+11 -54
View File
@@ -37,10 +37,7 @@ async def settings_lifespan():
import asyncio as _asyncio
async def _boot_router_then_sync():
"""Start 9Router (if any apikey-routed provider is configured)
then push our key-based connections into it. Sequential because
sync_* helpers no-op when 9Router isn't running yet — running
them post-boot guarantees the connections actually land."""
"""Boot 9Router then push key-based connections (sequential: sync helpers no-op pre-boot)."""
needs_router = any([
getattr(s, "google_api_key", None),
getattr(s, "openai_api_key", None),
@@ -76,13 +73,7 @@ settings = SubApp("settings", settings_lifespan)
def _migrate_legacy_fields(raw: dict) -> dict:
"""Translate deprecated field names/values so they survive into the new schema.
Pre-launch scaffolding used `connection_mode="managed"` and
`openswarm_auth_token`; production names are `"openswarm-pro"` and
`openswarm_bearer_token`. Zero known users are affected, but keep the
mapping for safety.
"""
"""Translate deprecated pre-launch field names ('managed', 'openswarm_auth_token') into production schema."""
if raw.get("connection_mode") == "managed":
raw["connection_mode"] = "openswarm-pro"
if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
@@ -102,26 +93,19 @@ def load_settings() -> AppSettings:
return AppSettings()
# Single threading.Lock guards every write to SETTINGS_FILE — protects against
# corruption from two requests racing through the file system. Async callers
# offload the actual write to the default thread pool (run_in_executor), so
# the lock works for both sync and thread-pool execution paths.
# threading.Lock guards every SETTINGS_FILE write; works for sync paths and async run_in_executor paths.
_settings_write_lock = threading.Lock()
def _atomic_write_settings(payload: dict) -> None:
"""Internal: serialise payload to SETTINGS_FILE atomically.
Always called via save_settings* don't invoke directly."""
"""Atomic SETTINGS_FILE write; call via save_settings*, not directly."""
with _settings_write_lock:
os.makedirs(DATA_DIR, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".settings.", suffix=".tmp", dir=DATA_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
# On Windows, os.replace can transiently fail with PermissionError
# if Defender or another reader holds the destination open. One
# retry after a short backoff handles every real-world case
# without masking genuine permission bugs.
# Windows: Defender can briefly lock the destination; one retry handles every real case.
for attempt in range(2):
try:
os.replace(tmp, SETTINGS_FILE)
@@ -139,24 +123,17 @@ def _atomic_write_settings(payload: dict) -> None:
def save_settings(settings_obj: AppSettings) -> None:
"""Synchronously persist settings atomically. Thread-safe.
Use from sync paths (analytics collector, lifespans). Async callers should
prefer save_settings_async to avoid blocking the event loop on Windows
where Defender scans can stretch the write to 50-200ms."""
"""Sync atomic persist; thread-safe. Async callers should prefer save_settings_async (Defender can stretch writes to 50-200ms)."""
_atomic_write_settings(settings_obj.model_dump())
async def save_settings_async(settings_obj: AppSettings) -> None:
"""Async-safe atomic save. Runs the file I/O in the default thread pool
so the FastAPI event loop stays responsive while the write completes.
Shares the threading.Lock with the sync variant for safe interleaving."""
"""Async atomic save via thread pool; shares the lock with the sync variant."""
payload = settings_obj.model_dump()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _atomic_write_settings, payload)
# Backward-compat alias. Existing sync callers (analytics collector, analytics
# lifespan) continue to work; new async callers should use save_settings_async.
def _save_settings(settings_obj: AppSettings) -> None:
save_settings(settings_obj)
@@ -172,14 +149,12 @@ async def update_settings(body: AppSettings):
old = load_settings()
# Sync the settings state (secrets stripped).
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
"openswarm_bearer_token", "installation_id"}
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
_sync(safe)
# Identify user in service-sync when profile is set/changed
if (body.user_email and body.user_email != getattr(old, "user_email", None)) or \
(body.user_name and body.user_name != getattr(old, "user_name", None)):
from backend.apps.service.client import identify as _identify
@@ -227,8 +202,7 @@ async def update_settings(body: AppSettings):
except Exception:
pass
# Boot+sync runs off the request path ensure_running() can take 5min
# on first install (npm pull) and would freeze the event loop.
# Off the request path: ensure_running() can take 5min on first install (npm pull) and would freeze the loop.
if google_changed or openai_changed or openrouter_changed or custom_providers_changed:
async def _boot_and_sync_keys(
google_key: str | None,
@@ -275,12 +249,7 @@ async def update_settings(body: AppSettings):
any_keyed_added,
))
# When openswarm-pro mode or bearer token changes, register a `claude`
# apikey connection in 9Router that proxies through our cloud. This
# makes the CLI's built-in WebSearch work on non-Claude primaries for
# Pro users — the CLI's Anthropic delegation path now has a working
# Claude route via 9Router, instead of hitting "no credentials for
# provider: claude".
# On pro-mode/bearer change, register a `claude` apikey connection in 9Router so CLI WebSearch works on non-Claude primaries.
pro_mode_old = getattr(old, "connection_mode", None) == "openswarm-pro"
pro_mode_new = getattr(body, "connection_mode", None) == "openswarm-pro"
bearer_old = getattr(old, "openswarm_bearer_token", None)
@@ -305,25 +274,13 @@ class AppThemeOverridePayload(BaseModel):
@settings.router.get("/app-theme-override")
async def get_app_theme_override():
"""Cross-app theme preference for App Builder workspaces.
Returns the current override (or `null` for follow-system). Apps
served from the template fetch this on mount so a toggle inside
any one app sticks across every future app the user builds. Each
app workspace runs on its own vite port (separate localStorage
origin), so the backend is the only place this can live."""
"""Cross-app theme preference for App Builder workspaces; backend-held because each app uses its own localStorage origin."""
return {"mode": load_settings().app_template_theme_override}
@settings.router.put("/app-theme-override")
async def put_app_theme_override(body: AppThemeOverridePayload):
"""MERGE the theme override into AppSettings. The general PUT
/api/settings endpoint replaces the whole AppSettings object
sending a partial body there would default every secret-bearing
field (api keys, subscription tokens), which logs the user out
and pops the SignInGate. This dedicated endpoint mutates only
`app_template_theme_override` and leaves every other field
untouched."""
"""MERGE the override; the general PUT /api/settings replaces the whole object and would blank secrets, logging the user out."""
current = load_settings()
current.app_template_theme_override = body.mode
await save_settings_async(current)
@@ -43,7 +43,7 @@ def _parse_frontmatter(raw: str) -> tuple[dict, str]:
async def _fetch_skill_paths(client: httpx.AsyncClient) -> list[tuple[str, str]]:
"""Fetch the marketplace.json manifest and return (skill_folder, plugin_name) pairs.
Uses raw.githubusercontent.com no GitHub API needed, no rate limiting.
Uses raw.githubusercontent.com; no GitHub API needed, no rate limiting.
"""
resp = await client.get(MANIFEST_URL)
resp.raise_for_status()
+1 -5
View File
@@ -10,11 +10,7 @@ class Skill(BaseModel):
content: str
file_path: str = ""
command: str = ""
# Skills that OpenSwarm ships as part of the platform (e.g. the App
# Builder reference) get this flag set. The UI hides the delete
# button for them and the DELETE endpoint refuses with 409. Content
# is still editable — the whole point is that users can tune how
# the platform-internal agents behave.
# Platform-shipped skills (e.g. App Builder): UI hides delete and DELETE returns 409, but content stays editable so users can tune them.
built_in: bool = False
+6 -6
View File
@@ -32,7 +32,7 @@ def _save_index(index: dict[str, dict]):
# skill file we copy into ~/.claude/skills/ on first boot and tag with
# `built_in: true` in the index. Users can edit the content (their
# changes flow through to the matching agent's prompt on the next turn),
# but they can't delete the file the DELETE endpoint refuses with 409.
# but they can't delete the file; the DELETE endpoint refuses with 409.
def _built_in_skill_registry() -> list[dict]:
# Imported lazily so this module stays cheap to import from
# everywhere (the skills outputs module pulls in pydantic+fastapi
@@ -47,7 +47,7 @@ def _built_in_skill_registry() -> list[dict]:
"name": "App Builder",
"description": (
"Reference doc the App Builder agent reads on every turn. "
"Edit this to change how every App Builder agent behaves "
"Edit this to change how every App Builder agent behaves; "
"your edits take effect on the next turn, no restart. "
"Built-in: can be edited but not deleted."
),
@@ -58,7 +58,7 @@ def _built_in_skill_registry() -> list[dict]:
"id": "swarm_debug_skill",
"name": "swarm-debug Logger",
"description": (
"How to use `swarm_debug.debug()` in an App backend the "
"How to use `swarm_debug.debug()` in an App backend; the "
"colored frame-aware logger that lands in the App Builder's "
"Terminal pane under [BACKEND]. Edit to teach your debugging "
"conventions to the App Builder agent. Built-in: editable, "
@@ -73,7 +73,7 @@ def _built_in_skill_registry() -> list[dict]:
def _seed_built_in_skills() -> None:
"""Copy each built-in skill into SKILLS_DIR if not already present, and
ensure the index has the `built_in: true` flag so the UI and DELETE
endpoint know to treat it specially. Idempotent safe to call on
endpoint know to treat it specially. Idempotent; safe to call on
every boot. Doesn't overwrite the file once it exists (so user edits
are preserved across restarts and upgrades)."""
index = _load_index()
@@ -114,7 +114,7 @@ async def skills_lifespan():
try:
_seed_built_in_skills()
except Exception:
# Don't block app startup on a skill-seed failure the worst
# Don't block app startup on a skill-seed failure; the worst
# case is the user has to manually paste the skill in once.
logger.exception("failed to seed built-in skills")
yield
@@ -296,7 +296,7 @@ async def delete_skill(skill_id: str):
status_code=409,
detail=(
f"'{skill_id}' is a built-in skill and can't be deleted "
"(edit its content instead your edits take effect on "
"(edit its content instead; your edits take effect on "
"the next agent turn)."
),
)
+6 -6
View File
@@ -41,7 +41,7 @@ async def _clear_subscription(settings_obj, *, drop_bearer: bool = True) -> None
`drop_bearer=True` (the default) is the original behavior, used when the
cloud reports the bearer as revoked (401) or the subscription as past its
grace period (402) the bearer is dead so we have to clear it.
grace period (402); the bearer is dead so we have to clear it.
`drop_bearer=False` is used by the explicit user-initiated /disconnect
endpoint: the bearer still authenticates the user's account at api.me
@@ -62,7 +62,7 @@ async def _clear_subscription(settings_obj, *, drop_bearer: bool = True) -> None
def _sync_subscription_identity(settings_obj) -> None:
"""Push the installation's current subscription state into service-sync person
properties so every event from this user is segmentable by plan /
paying-vs-free. Safe to call from hot paths service-sync is fire-and-forget
paying-vs-free. Safe to call from hot paths; service-sync is fire-and-forget
and swallows errors internally."""
try:
from backend.apps.service.client import identify as _identify
@@ -178,7 +178,7 @@ async def status():
"connection_mode": mode,
}
# Best-effort live fetch surface stale cache if cloud is unreachable.
# Best-effort live fetch; surface stale cache if cloud is unreachable.
# Network errors leave upstream_code=None so we keep the cached state;
# only explicit 401/402 from the cloud trigger a local clear.
live_usage = None
@@ -203,7 +203,7 @@ async def status():
logger.debug("subscription/status live fetch failed: %s", e)
# Cloud says the bearer is gone (401) or the sub is past its grace
# period (402) drop local credentials so the desktop stops routing
# period (402); drop local credentials so the desktop stops routing
# through a dead subscription. Settings UI sees connected=False and
# falls back to the Subscribe CTA; chat reverts to own_key routing.
if upstream_code in (401, 402):
@@ -237,7 +237,7 @@ async def sync():
state forever.
No-op when not in openswarm-pro mode. Best-effort: network failures are
swallowed the caller still gets a 200 with whatever local state we
swallowed; the caller still gets a 200 with whatever local state we
already had."""
# Lazy-import the service-sync helper so subscription/router doesn't pay the
# cost when analytics are disabled.
@@ -285,7 +285,7 @@ async def sync():
cloud_plan = data.get("plan")
period_end_ms = data.get("current_period_end")
# Only touch local fields the cloud explicitly confirmed don't paper
# Only touch local fields the cloud explicitly confirmed; don't paper
# over missing keys with defaults that would downgrade an older record.
if cloud_plan:
settings_obj.openswarm_subscription_plan = cloud_plan
+13 -5
View File
@@ -30,17 +30,25 @@ BUILTIN_TOOLS: list[BuiltinTool] = [
BuiltinTool(name="EnterWorktree", description="Enter a git worktree for isolated work", category="system", deferred=True),
BuiltinTool(name="TaskOutput", description="Read output from a background task", category="system", deferred=True),
BuiltinTool(name="TaskStop", description="Stop a running background task", category="system", deferred=True),
BuiltinTool(name="CronCreate", description="Create a scheduled or recurring task", category="scheduling", deferred=True),
BuiltinTool(name="CronList", description="List all scheduled tasks", category="scheduling", deferred=True),
BuiltinTool(name="CronDelete", description="Delete a scheduled task", category="scheduling", deferred=True),
# CronCreate/List/Delete are kept for compatibility with the Claude
# Agent SDK's task system but are intentionally NOT how users in
# OpenSwarm should schedule recurring work. The native scheduler
# ("Schedule" button in any chat header, or /schedule slash command)
# gives the workflow a card on the canvas, a calendar entry, audit
# logs, cost caps, and a Pause-all toggle. Cron entries are invisible
# to the platform and survive uninstall. The descriptions below tell
# the agent so it picks the right path.
BuiltinTool(name="CronCreate", description="Schedule a one-off background task within the current session (NOT for recurring user workflows; use the user-visible 'Schedule' button / native workflow scheduler for anything the user wants to repeat on a real-world calendar)", category="scheduling", deferred=True),
BuiltinTool(name="CronList", description="List background tasks in the current session (NOT user-facing scheduled workflows; those live in the Workflows hub)", category="scheduling", deferred=True),
BuiltinTool(name="CronDelete", description="Delete a background task in the current session (NOT a user-facing scheduled workflow)", category="scheduling", deferred=True),
# Agent tools
BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"),
BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"),
# Browser delegation tools (Layer 1 what the main agent calls)
# Browser delegation tools (Layer 1; what the main agent calls)
BuiltinTool(name="CreateBrowserAgent", description="Create a new browser and run a task on it", category="browser_delegation"),
BuiltinTool(name="BrowserAgent", description="Delegate a browser task to an existing browser agent", category="browser_delegation"),
BuiltinTool(name="BrowserAgents", description="Run multiple browser tasks in parallel on existing browsers", category="browser_delegation"),
# Browser action tools (Layer 2 what the sub-agent executes)
# Browser action tools (Layer 2; what the sub-agent executes)
BuiltinTool(name="BrowserScreenshot", description="Capture a screenshot of the browser page", category="browser_action"),
BuiltinTool(name="BrowserNavigate", description="Navigate the browser to a URL", category="browser_action"),
BuiltinTool(name="BrowserClick", description="Click an element by CSS selector", category="browser_action"),
+16 -16
View File
@@ -71,7 +71,7 @@ def _load(tool_id: str) -> ToolDefinition:
tool = ToolDefinition(**json.load(f))
# Migrate Discord tool configs from the old npx-based spawn (which
# broke whenever the npx cache was partially populated) to the local
# Python shim. Idempotent if it's already on the shim, no-op.
# Python shim. Idempotent; if it's already on the shim, no-op.
if (
tool.name.lower() == "discord"
and tool.mcp_config
@@ -224,7 +224,7 @@ def _resolve_command(command: str) -> str | None:
if found:
return found
# Windows binaries need an extension. shutil.which() handles PATHEXT for
# PATH lookups, but we manually scan _extra_bin_dirs below replicate
# PATH lookups, but we manually scan _extra_bin_dirs below; replicate
# the suffix probing here so `uvx` finds `uvx.exe`, etc.
if sys.platform == "win32":
suffixes = [""] + os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").lower().split(os.pathsep)
@@ -320,7 +320,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
if guild_ids:
env["OPENSWARM_DISCORD_GUILD_IDS"] = ",".join(guild_ids)
# The shim runs as a subprocess and needs to import
# `backend.apps.discord_mcp_shim` set PYTHONPATH to the project
# `backend.apps.discord_mcp_shim`; set PYTHONPATH to the project
# root (parent of the backend/ dir) so that import resolves.
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
existing_pp = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
@@ -338,7 +338,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
if config.get("command"):
# `python` (no version suffix) doesn't exist on a stock macOS,
# so a tool config that asks for "python" silently fails to
# spawn Claude Agent SDK then exposes zero tools from that
# spawn; Claude Agent SDK then exposes zero tools from that
# MCP. We resolve to the actual interpreter running the
# backend (sys.executable), which is guaranteed to exist and
# have backend modules importable. `python3` and absolute
@@ -347,7 +347,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
resolved_python = sys.executable or shutil.which("python3") or shutil.which("python")
if resolved_python:
config["command"] = resolved_python
# Check for bundled npm MCP servers use Electron's Node.js instead of npx
# Check for bundled npm MCP servers; use Electron's Node.js instead of npx
if config["command"] in ("npx", "bunx"):
pkg_name = next((a for a in (config.get("args") or []) if not a.startswith("-")), None)
if pkg_name:
@@ -425,7 +425,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
env = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path())
env.setdefault("PYTHONPATH", "")
# Point uv/uvx at our bundled Python avoids macOS CLT popup on fresh Macs
# Point uv/uvx at our bundled Python; avoids macOS CLT popup on fresh Macs
# and avoids downloading Python at runtime
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
_is_windows = sys.platform == "win32"
@@ -607,7 +607,7 @@ def _try_heal_npx_cache(stderr: str) -> str | None:
Why: interrupted npx installs leave a `package-lock.json` in the cache dir so
subsequent spawns reuse a partially-extracted node_modules tree, which dies at
import time. Scoped strictly to the extracted hash subdir never touches
import time. Scoped strictly to the extracted hash subdir; never touches
anything outside `~/.npm/_npx/`.
"""
if "ERR_MODULE_NOT_FOUND" not in stderr:
@@ -729,7 +729,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
except HTTPException as e:
# Heal-on-corrupt-npx-cache still triggers from the EOF branch,
# which now includes the full stderr tail in `e.detail` so the
# which now includes the full stderr tail in `e.detail`; so the
# ERR_MODULE_NOT_FOUND signature is still discoverable here.
if _attempt == 0 and _try_heal_npx_cache(str(e.detail) if e.detail is not None else ""):
return await _discover_mcp_tools_stdio(command, args, env, _attempt=1)
@@ -737,10 +737,10 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
except asyncio.TimeoutError:
# Most common cause: cold npx cache on Windows. The npm install
# persists across attempts, so a retry usually finishes against a
# warm cache. Surface npx's own progress line if we have one it
# warm cache. Surface npx's own progress line if we have one; it
# makes the cause obvious ("downloading X...") instead of opaque.
tail_text = "".join(stderr_tail[-5:]).strip()
detail = "MCP discovery timed out the server may still be downloading on first run"
detail = "MCP discovery timed out; the server may still be downloading on first run"
if tail_text:
preview = tail_text[-200:].replace("\n", " ").strip()
detail += f" (last output: {preview})"
@@ -871,7 +871,7 @@ def _m365_server_script() -> str:
backend/mcp-bundles/softeria-ms-365-mcp-server/dist/index.js (4.7MB).
The new path mirrors the SDK's internal layout (dist/index.js + sibling
package.json) because cli.js reads __dirname/../package.json for the
--version flag see scripts/build-app.sh `build_mcp_bundle_dir`.
--version flag; see scripts/build-app.sh `build_mcp_bundle_dir`.
"""
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
bundle = os.path.join(
@@ -955,7 +955,7 @@ async def m365_device_login(tool_id: str):
login_state["status"] = "awaiting_auth"
if url_match and "microsoft" in url_match.group(1).lower():
login_state["device_code_url"] = url_match.group(1)
# Process ended check result
# Process ended; check result
proc.wait()
remaining_stderr = proc.stderr.read() if proc.stderr else ""
login_state["output"] += remaining_stderr
@@ -1163,7 +1163,7 @@ async def oauth_cloud_claim(
data = resp.json()
tokens = data.get("tokens", {}) or {}
tool = _load(tool_id)
# Google's token endpoint doesn't include the user's email fetch it
# Google's token endpoint doesn't include the user's email; fetch it
# from userinfo so the UI can show "you connected ericzeng@gmail.com"
# rather than the generic "Google account" placeholder.
if tool.name.lower() == "google" and tokens.get("access_token") and not tokens.get("email"):
@@ -1186,7 +1186,7 @@ def _persist_cloud_tokens(tool: ToolDefinition, tokens: dict) -> None:
"""Normalise the cloud's claim response into tool.oauth_tokens.
Per-provider shaping mirrors what the v1.0.25 local-callback flow used
to write the rest of the app (refresh helpers, MCP env injection)
to write; the rest of the app (refresh helpers, MCP env injection)
expects exactly this shape.
"""
name = tool.name.lower()
@@ -1242,7 +1242,7 @@ async def _refresh_via_proxy(provider: str, tool: ToolDefinition, default_expiry
json={"refresh_token": refresh_token},
)
if resp.status_code == 401:
# Provider rejected user revoked at the provider's side. Mark
# Provider rejected; user revoked at the provider's side. Mark
# as needing re-auth so the UI prompts a Reconnect.
tool.auth_status = "expired"
_save(tool)
@@ -1275,7 +1275,7 @@ async def _refresh_via_proxy(provider: str, tool: ToolDefinition, default_expiry
async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired Google access_token via the Fly cloud-proxy.
The client_secret never leaves Fly desktop only POSTs the
The client_secret never leaves Fly; desktop only POSTs the
refresh_token. Same pattern as Airtable/HubSpot. Pre-v1.0.29 builds
held the secret in their bundled .env; v1.0.29 removed it.
"""
+8 -8
View File
@@ -41,7 +41,7 @@ class SearchBody(BaseModel):
num_results: int = Field(5, ge=1, le=10, description="Max results to return.")
# Hint from the MCP server about which primary provider the session
# is using. Lets us route to that provider's native search tool
# (Gemini googleSearch, OpenAI web_search_preview) when available
# (Gemini googleSearch, OpenAI web_search_preview) when available ,
# costs come out of the user's existing primary budget.
primary: str | None = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None")
@@ -53,7 +53,7 @@ class FetchBody(BaseModel):
# ---------------------------------------------------------------------------
# Helper extract plain text from a tool's structured output list
# Helper; extract plain text from a tool's structured output list
# ---------------------------------------------------------------------------
@@ -143,7 +143,7 @@ def _format_grounded_as_fetch(grounded: dict, url: str) -> str:
if chunks:
parts.append("\nCited sources:")
for i, (title, uri) in enumerate(chunks[:5], start=1):
parts.append(f" [{i}] {title} {uri}")
parts.append(f" [{i}] {title}; {uri}")
return "\n".join(parts)
@@ -167,7 +167,7 @@ def _resolve_openai_api_key() -> str | None:
# Cache of which 9Router subscriptions are connected. Refreshed via
# `_refresh_9r_connected()` rather than hit on every search call
# `_refresh_9r_connected()` rather than hit on every search call ,
# 9Router's /api/providers is fast but not free, and we already
# query it from many places.
_NINE_ROUTER_CONNECTED: set[str] = set()
@@ -196,7 +196,7 @@ async def _refresh_9r_connected() -> set[str]:
}
_NINE_ROUTER_CACHE_AT = now
except Exception:
# Cache stays best-effort.
# Cache stays; best-effort.
pass
return _NINE_ROUTER_CONNECTED
@@ -207,7 +207,7 @@ async def _gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> di
search call instead of needing a separate AI Studio API key.
Routes through Anthropic-shape against 9Router's translator. We
request a tool result naturally the translator surfaces grounded
request a tool result naturally; the translator surfaces grounded
URIs as text + cited sources in the response body. Format-shape
matches the existing `_gemini_grounded_call` so downstream
`_format_grounded_as_search_results` works unchanged."""
@@ -482,14 +482,14 @@ async def search(body: SearchBody) -> dict:
has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"})
if not (gemini_key or openai_key or has_subscription):
hint = (
"\n\n(DuckDuckGo returned no results likely rate-limiting this IP. "
"\n\n(DuckDuckGo returned no results; likely rate-limiting this IP. "
"Connect Codex / Antigravity / Gemini CLI in Settings, or add an "
"OpenAI / Gemini API key, for reliable native search.)"
)
else:
hint = (
"\n\n(DuckDuckGo returned no results and the connected providers "
"didn't return useful results either try rephrasing the query.)"
"didn't return useful results either; try rephrasing the query.)"
)
return {
"query": body.query,
+26 -8
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, Literal, Any
from datetime import datetime
from uuid import uuid4
@@ -15,11 +15,15 @@ class PermissionTier(BaseModel):
class ScheduleConfig(BaseModel):
enabled: bool = False
repeat_every: int = 1
# Bounds keep the scheduler from blowing up on malformed input. The
# FE clamps these too, but defense-in-depth: a misbehaving agent
# tool, an old JSON file, or a curl-wielding power user shouldn't
# be able to crash _next_fire_after by passing hour=99.
repeat_every: int = Field(default=1, ge=1, le=365)
repeat_unit: Literal["day", "week", "month"] = "week"
on_days: list[int] = Field(default_factory=list)
hour: int = 9
minute: int = 0
hour: int = Field(default=9, ge=0, le=23)
minute: int = Field(default=0, ge=0, le=59)
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy
# records that predate explicit tz. storage._load_all_from_disk coerces
# "local" to the host zone in memory; we leave it on disk until the
@@ -30,8 +34,22 @@ class ScheduleConfig(BaseModel):
# disables once either is satisfied; scheduler._tick zeroes out
# next_run_at and flips enabled=False so the UI reflects reality.
ends_at: Optional[datetime] = None
max_runs: Optional[int] = None
runs_count: int = 0
max_runs: Optional[int] = Field(default=None, ge=1)
runs_count: int = Field(default=0, ge=0)
@field_validator("on_days")
@classmethod
def _clean_on_days(cls, v: list[int]) -> list[int]:
# Backend uses JS-style weekday (Sun=0..Sat=6). Drop entries
# outside that range so a malformed PATCH can't trip the
# scheduler later, and dedupe while preserving order.
seen: set[int] = set()
out: list[int] = []
for d in v or []:
if isinstance(d, int) and 0 <= d <= 6 and d not in seen:
seen.add(d)
out.append(d)
return out
class ActionsConfig(BaseModel):
@@ -50,8 +68,8 @@ class Workflow(BaseModel):
# (workflows.py:update_workflow setattr's raw dicts from body.model_dump
# straight onto the cached Workflow). Without coercion the nested
# schedule/steps/actions/permissions fields become plain dicts in
# memory, and every downstream call scheduler tick, executor.execute,
# subsequent PATCHes crashes on `.enabled` / `.text`.
# memory, and every downstream call; scheduler tick, executor.execute,
# subsequent PATCHes; crashes on `.enabled` / `.text`.
model_config = ConfigDict(validate_assignment=True)
id: str = Field(default_factory=lambda: uuid4().hex)
+56
View File
@@ -18,10 +18,58 @@ from backend.apps.workflows import storage, scheduler, executor, audit, escalati
logger = logging.getLogger(__name__)
def _scan_cron_for_openswarm() -> list[str]:
"""Surface OS-level scheduled-task entries that reference us.
macOS + Linux: read `crontab -l`. Windows: query `schtasks` for any
task whose command/path contains 'openswarm'. Best-effort across all
three; any failure (no tool installed, permission denied, parse
error) just returns []. Surfaced to the FE so the Workflows hub can
offer a one-click migration banner to convert into native workflows.
"""
import subprocess
import platform as _platform
findings: list[str] = []
if _platform.system() == "Windows":
try:
proc = subprocess.run(
["schtasks", "/query", "/fo", "CSV", "/v"],
capture_output=True, text=True, timeout=4,
)
if proc.returncode != 0:
return []
for line in (proc.stdout or "").splitlines():
if "openswarm" in line.lower() and not line.lstrip().startswith('"#'):
findings.append(line.strip())
except Exception:
return []
return findings
# macOS + Linux
try:
proc = subprocess.run(
["crontab", "-l"],
capture_output=True, text=True, timeout=2,
)
if proc.returncode != 0:
return []
out = proc.stdout or ""
return [line.strip() for line in out.splitlines() if "openswarm" in line.lower() and not line.strip().startswith("#")]
except Exception:
return []
_cron_findings: list[str] = []
@asynccontextmanager
async def workflows_lifespan():
storage.init()
await scheduler.start()
# Cheap one-shot scan for prior cron entries that reference us. We
# don't migrate automatically; the FE shows a banner with a "Convert
# to OpenSwarm scheduled tasks" button so the user is in control.
global _cron_findings
_cron_findings = _scan_cron_for_openswarm()
try:
yield
finally:
@@ -146,6 +194,14 @@ async def get_paused_state():
return {"paused": storage.get_paused()}
@workflows.router.get("/cron/findings")
async def cron_findings():
"""Cron entries we found at startup that reference OpenSwarm. The
FE renders a one-time banner inviting users to convert them; we
return the raw lines so the user can verify before migrating."""
return {"entries": list(_cron_findings)}
@workflows.router.get("/cloud/sms/status")
async def cloud_sms_status():
"""Probe used by the FE to decide whether to show the 'falls back to
+20 -184
View File
@@ -1,28 +1,4 @@
"""Per-install auth token for the localhost API.
OpenSwarm's backend runs a FastAPI server on `127.0.0.1:<random-port>`
and streams sensitive agent data (tool inputs, approval requests,
messages) over WebSockets. Without auth, any webpage loaded in any
browser on the same machine can connect to those endpoints WebSockets
aren't subject to Same-Origin Policy — and impersonate the user.
This module issues a cryptographically random token on first boot,
writes it 0600 to `<DATA_ROOT>/auth.token`, and reuses it on subsequent
restarts (so dev-mode hot-reload doesn't break the renderer's cached
copy). The token is regenerated only when the file is missing or empty.
Only code running as the same OS user can read the file.
Delivery to legitimate consumers:
- Electron main process reads the file and exposes it to the renderer
via a contextBridge method in preload.js (NOT plain window global).
- Our Python MCP subprocesses receive it via env var
`OPENSWARM_AUTH_TOKEN` that agent_manager passes when spawning.
- The Claude Code CLI we spawn receives it as `ANTHROPIC_API_KEY` in
env; the anthropic-proxy route trusts that value.
None of those paths are accessible from a third-party webpage.
"""
"""Per-install bearer token gating the localhost API and WS streams."""
from __future__ import annotations
@@ -38,13 +14,7 @@ _TOKEN: str = ""
def _write_atomic(path: str, data: str, mode: int = 0o600) -> None:
"""Write `data` to `path` atomically with the given file mode.
Uses `os.open(..., O_CREAT|O_WRONLY|O_TRUNC, mode)` + rename so the
final file is never world-readable and never left half-written if
the backend crashes mid-write. Windows-safe (rename of a file over
an existing one works on NTFS when the source was just closed).
"""
"""Atomic write to `path` at the given file mode; never world-readable or half-written."""
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, mode)
@@ -60,30 +30,8 @@ def _write_atomic(path: str, data: str, mode: int = 0o600) -> None:
def init_auth_token() -> str:
"""Initialise the per-install auth token, persisting to disk.
Behaviour: prefer an existing token on disk; only mint a fresh one
when the file is absent or empty. This matters for two cases:
1. Dev mode (`bash run.sh`) uvicorn's WatchFiles reload restarts
the worker process and re-runs init_auth_token. If we generated
a fresh token every reload, Electron's cached token (read once
at app boot) would mismatch and every authed request 401s
until the user fully restarts. Preserving the on-disk token
keeps Electron and the backend in sync across reloads.
2. Packaged builds the user can restart the backend (Quit + reopen)
without the renderer reloading. Same mismatch risk, same fix.
Security trade-off: we no longer rotate the token on every restart.
The threat model that rotation was protecting against (a stale token
sitting in a log/crash dump being usable later) is marginal anyone
who can read the artifact can also re-read the on-disk token, and
real rotation requires the file to be deleted (e.g. by signing out
or wiping the data root). Net: dev-mode reliability wins.
"""
"""Load the per-install token from disk, or mint one if missing; reused across restarts so Electron's cached copy stays valid."""
global _TOKEN
# Try existing on-disk token first.
try:
if os.path.exists(AUTH_TOKEN_FILE):
with open(AUTH_TOKEN_FILE, "r", encoding="utf-8") as f:
@@ -95,7 +43,6 @@ def init_auth_token() -> str:
)
return _TOKEN
except Exception as e:
# Fall through to fresh generation on any read error.
logger.warning(f"auth: failed to read existing token, generating new: {e}")
_TOKEN = secrets.token_urlsafe(32)
@@ -103,9 +50,7 @@ def init_auth_token() -> str:
_write_atomic(AUTH_TOKEN_FILE, _TOKEN, mode=0o600)
logger.info(f"auth: wrote token to {AUTH_TOKEN_FILE} (mode 0600)")
except Exception as e:
# Fail open is NOT an option here — if we can't write the file,
# Electron can't read it, and the user sees a broken app. But
# don't hard-crash the backend either; log loudly.
# If we can't write the file, Electron can't read it; log loudly but don't crash.
logger.error(f"auth: failed to write token file: {e}")
return _TOKEN
@@ -116,25 +61,13 @@ def get_auth_token() -> str:
class _TokenScrubFilter(logging.Filter):
"""Logging filter that redacts the install token from any log record.
The token leaks into logs in a few mundane ways: subprocess env dicts
that get logged when an MCP server fails to spawn, urllib retry logs
that include `?token=...` query strings, exception tracebacks that
print the response body of a failed proxied request. None of those
are intentional but they all happen, and the file ends up in crash
dumps / shared bug reports / hosted log aggregators. This filter is
pure defense in depth behavior is unchanged when the token is
absent from a record.
"""
"""Logging filter that redacts the install token from log records (defense in depth)."""
_PLACEHOLDER = "<REDACTED:openswarm-token>"
@staticmethod
def _args_might_contain_token(args) -> bool:
"""Cheap pre-check: does any positional arg or dict-arg value mention
the token? Avoids the cost of `record.getMessage()` (which eagerly
does %-formatting) on the >99% of log lines that don't touch it."""
"""Cheap pre-check; avoids eager %-formatting on the >99% of records that don't mention the token."""
if not args:
return False
items = args if isinstance(args, (tuple, list)) else (args,)
@@ -149,13 +82,7 @@ class _TokenScrubFilter(logging.Filter):
@classmethod
def _scrub_args(cls, args):
"""Replace token within args in-place-equivalent, preserving the
original tuple/dict shape. Uvicorn's AccessFormatter unpacks
record.args as a 5-tuple (client_addr, method, full_path,
http_version, status_code); blanking args to None what the
previous slow path did triggered `cannot unpack non-iterable
NoneType object` for every access-logged line that contained
`?token=...`. Returns the same object if nothing was rewritten."""
"""Scrub token from args while preserving tuple/dict shape; uvicorn's AccessFormatter unpacks args as a 5-tuple and explodes on None."""
if args is None:
return args
if isinstance(args, dict):
@@ -181,27 +108,11 @@ class _TokenScrubFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover (defensive)
if not _TOKEN:
return True
# Fast path: the overwhelming majority of log records don't mention
# the token at all. Two cheap string-in-string scans (raw msg + each
# arg) are far cheaper than forcing record.getMessage(), which would
# do eager %-formatting on every record in the process.
# Fast path: skip eager %-formatting on records that don't mention the token.
raw_msg = record.msg if isinstance(record.msg, str) else ""
if _TOKEN not in raw_msg and not self._args_might_contain_token(record.args):
return True
# Slow path. Two-step scrub so we cover both shapes:
# 1. In-place rewrite of record.msg and any string in record.args
# (or string-valued dict entry). Preserves args shape so
# uvicorn's AccessFormatter — which unpacks record.args as a
# 5-tuple and would explode on args=None — keeps working.
# 2. Render via record.getMessage() and check the substituted
# output. If a token survived step 1 (because it was buried
# inside a nested structure or a custom object's repr, e.g.
# `logger.info("env: %s", env_dict)` where the dict's repr
# exposes the value), bake the redacted final string into
# record.msg and clear args. This last-resort path only
# trips for records that the in-place pass couldn't reach,
# and uvicorn access logs never hit it (their args are
# always primitive strings/ints, fully scrubbed by step 1).
# Slow path: in-place args rewrite (preserves shape for AccessFormatter), then re-render to catch tokens buried in custom reprs.
try:
if isinstance(record.msg, str) and _TOKEN in record.msg:
record.msg = record.msg.replace(_TOKEN, self._PLACEHOLDER)
@@ -216,9 +127,7 @@ class _TokenScrubFilter(logging.Filter):
except Exception:
pass
except Exception:
# Never let the scrubber suppress a log line — if formatting
# fails for any reason, fall through and let normal handling
# proceed (worst case the token leaks for that one record).
# Never let the scrubber suppress a log line; worst case the token leaks for that one record.
pass
return True
@@ -227,27 +136,7 @@ _scrubber_installed = False
def install_token_scrubber() -> None:
"""Attach the token-scrubbing filter to every log handler in the process.
Why not just `root.addFilter(...)`: filters attached to a Logger only
fire on records emitted DIRECTLY on that logger. Records propagated up
from child loggers (uvicorn.access, uvicorn.error, websockets, etc.)
flow into root's HANDLERS without ever consulting root's logger-level
filters. So a logger-level install silently misses the access log
which is exactly the line that contains `?token=...` query params.
This implementation:
1. Walks every currently-registered logger (root + everything in
`Logger.manager.loggerDict`) and attaches the scrubber to each
of their handlers.
2. Monkey-patches `Logger.addHandler` so any handler installed
AFTER this call (uvicorn configures its loggers during startup,
after main.py finishes importing) also gets the scrubber.
3. Keeps the root-logger filter as belt-and-suspenders for the
records that ARE emitted directly on root.
Idempotent repeated calls are a no-op.
"""
"""Attach the scrubbing filter to every existing AND future log handler; logger-level filters miss propagated child records."""
global _scrubber_installed
if _scrubber_installed:
return
@@ -258,7 +147,6 @@ def install_token_scrubber() -> None:
if not any(isinstance(f, _TokenScrubFilter) for f in handler.filters):
handler.addFilter(scrubber)
# 1. Existing handlers across every known logger.
loggers: list[logging.Logger] = [logging.getLogger()]
for logger in logging.root.manager.loggerDict.values():
if isinstance(logger, logging.Logger):
@@ -267,9 +155,7 @@ def install_token_scrubber() -> None:
for h in list(logger.handlers):
_attach(h)
# 2. Future handlers — patch Logger.addHandler so anything attached
# after this point (uvicorn finishing its log config, plugins
# that reconfigure logging on their own) also gets scrubbed.
# Patch addHandler so handlers attached later (uvicorn finishes log config after main.py imports) get the scrubber too.
_original_addHandler = logging.Logger.addHandler
def _patched_addHandler(self: logging.Logger, hdlr: logging.Handler) -> None:
@@ -278,7 +164,6 @@ def install_token_scrubber() -> None:
logging.Logger.addHandler = _patched_addHandler # type: ignore[assignment]
# 3. Belt-and-suspenders on root logger itself.
root = logging.getLogger()
if not any(isinstance(f, _TokenScrubFilter) for f in root.filters):
root.addFilter(scrubber)
@@ -286,52 +171,21 @@ def install_token_scrubber() -> None:
_scrubber_installed = True
# Paths that never require auth. These are the public surface.
# Auth-exempt paths: external redirects with their own nonce/state validation, plus the bootstrap health probe.
_AUTH_EXEMPT_EXACT = {
# External OAuth providers redirect the user's browser here. The
# browser has no way to inject our bearer token (it's a 302 from
# Google/Anthropic/etc). The `state` query param is already a
# one-time nonce validated against `_pending_oauth`.
"/api/subscriptions/callback",
# Same pattern for the per-tool OAuth flow (Notion / Google Workspace /
# Airtable / HubSpot / Discord). The browser hits this with ?code=...&state=...
# after the user approves on the provider's site; the `state` param is
# the tool_id which we cross-check against _pending_oauth in tools_lib.py.
# Without this exemption the redirect lands a 401 page in the user's
# browser — see tools_lib.py:1156 where redirect_uri is constructed.
"/api/tools/oauth/callback",
# Browser-redirect target for the proxied OAuth claim handoff. Browser
# has no way to inject our bearer token; the install_id check inside
# the handler is what binds the request to this user.
"/api/tools/oauth/cloud-claim",
# Bearer-handoff endpoints called by api.openswarm.com's success page
# AFTER Stripe checkout / Google sign-in / magic-link sign-in. The
# request POSTs the just-minted cloud bearer; the handler then re-
# validates it against the cloud (/api/me or /api/auth/signin-activate).
# The browser has no way to attach our per-install token here — the
# cloud-validated bearer in the body is the actual auth mechanism.
"/api/subscription/activate",
"/api/auth/signin-activate",
"/api/version",
}
# Path prefixes that never require auth. Trailing slash optional.
_AUTH_EXEMPT_PREFIX = (
# Electron's boot handshake polls /api/health/check before it has a
# token (the HTTP port is up before main.js calls loadAuthToken()).
# Use a prefix so /api/health/check — and any future sub-route — is
# covered without re-introducing the bootstrap deadlock that an
# exact "/api/health" match caused.
# Electron polls /api/health/check before loading the token.
"/api/health",
# OpenAI API pass-through. 9Router calls this with the user's
# OpenAI Bearer token (sk-…), NOT our local auth token, so our
# middleware would reject. Localhost-only network boundary is the
# security gate — the route only forwards to api.openai.com and
# never touches user data on this machine. See
# backend/apps/agents/openai_passthrough.py for why this exists.
# 9Router proxies OpenAI requests with the user's sk-... bearer, not our local token; localhost-only is the gate.
"/api/openai-passthrough",
# FastAPI's default health/docs/schema surface (packaged app never
# ships /docs, but be defensive).
"/docs",
"/openapi",
"/redoc",
@@ -361,20 +215,9 @@ def extract_bearer(header_value: str | None) -> str:
def request_matches_token(request_headers: dict, query_params: dict | None = None) -> bool:
"""Validate that an incoming HTTP / WS request carries our token.
Accepts any of:
- `Authorization: Bearer <token>`
- `x-openswarm-token: <token>` (custom header for callers that
can't easily set Authorization — e.g. future CLI clients)
- `?token=<token>` query param (WS only; browsers can't easily
set custom WS headers, so the token rides in the URL)
The token comparison is constant-time via `secrets.compare_digest`.
"""
"""Validate that an HTTP/WS request carries our token (Bearer, x-openswarm-token, or ?token=); constant-time compare."""
if not _TOKEN:
# Backend started without auth init — fail closed. This should
# only happen in test fixtures that intentionally bypass main.
# Backend not initialized: fail closed. Only test fixtures that bypass main hit this.
return False
candidates: list[str] = []
@@ -402,14 +245,10 @@ def request_matches_token(request_headers: dict, query_params: dict | None = Non
return False
# Origin allowlist for WS handshakes. Electron's renderer loads from
# `file://` when packaged; `http://localhost:3000` (Vite dev server) and
# `http://127.0.0.1:3000` in dev. A bare `null` Origin is sent by some
# Electron contexts.
# WS Origin allowlist: Electron packaged is file://, dev is localhost:3000, some Electron contexts send bare "null".
_ORIGIN_ALLOWLIST_DEV = {
"http://localhost:3000",
"http://127.0.0.1:3000",
# Electron may load prod build from file:// or an app:// scheme.
"file://",
"null",
}
@@ -418,16 +257,13 @@ _ORIGIN_ALLOWLIST_DEV = {
def is_origin_allowed(origin: str | None) -> bool:
"""True if the WS connection's Origin header is from our app."""
if origin is None:
# No Origin header = curl / native WS client / MCP subprocess.
# Token check is still required, so allow.
# Native WS client / curl / MCP subprocess: token check still required, so allow.
return True
if origin in _ORIGIN_ALLOWLIST_DEV:
return True
# file:// origins in Electron prod sometimes include paths like
# file:///Applications/OpenSwarm.app/... — match by prefix.
# Packaged Electron file:// includes paths like file:///Applications/OpenSwarm.app/...; match by prefix.
if origin.startswith("file://"):
return True
# localhost + any port (dev servers, tools the developer is running).
if origin.startswith("http://localhost:") or origin.startswith("http://127.0.0.1:"):
return True
return False
+1 -2
View File
@@ -37,8 +37,7 @@ class MainApp:
yield
self.app = FastAPI(lifespan=lifespan)
# Include all sub-app routers in the main app with their prefixes
for sub_app in sub_apps:
self.app.include_router(
sub_app.router,
+2 -19
View File
@@ -1,13 +1,4 @@
"""Per-install identifier.
A UUID4 generated on first run, persisted at ``<DATA_ROOT>/install_id``
with 0600 perms. Used to bind an in-flight OAuth claim to the install
that started it, so a leaked session_id alone is useless.
Not a secret. Not a user identity. Not stable across reinstalls
(reinstalling generates a new ID, by design the previous install's
in-flight OAuth flows shouldn't follow the user across reinstalls).
"""
"""Per-install UUID4 binding in-flight OAuth claims to the install that started them."""
from __future__ import annotations
@@ -21,12 +12,7 @@ _cached: str | None = None
def get_install_id() -> str:
"""Return the persistent install_id, generating + persisting on first call.
Idempotent across processes if the file already exists we read it.
Concurrent first-call from two processes is safe: both write a UUID,
last-writer-wins, neither side cares which one is canonical.
"""
"""Return the persistent install_id, generating and persisting on first call."""
global _cached
if _cached:
return _cached
@@ -40,13 +26,10 @@ def get_install_id() -> str:
except FileNotFoundError:
pass
except Exception:
# Corrupt file — overwrite below.
pass
fresh = str(uuid.uuid4())
os.makedirs(os.path.dirname(_INSTALL_ID_FILE) or ".", exist_ok=True)
# 0600 so other accounts on the same machine can't read it. We're not
# treating it as a secret, but no reason to be sloppy.
fd = os.open(_INSTALL_ID_FILE, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
try:
os.write(fd, fresh.encode("utf-8"))
+2 -12
View File
@@ -1,10 +1,4 @@
"""Centralised path definitions for the OpenSwarm backend.
In dev mode (default) data lives under ``backend/data/``.
When packaged as a desktop app, Electron sets ``OPENSWARM_PACKAGED=1`` and
data is stored in a platform-appropriate location
(``~/Library/Application Support/OpenSwarm/data/`` on macOS).
"""
"""Path definitions: dev under backend/data/, packaged under platform app-support."""
import os
import sys
@@ -35,11 +29,7 @@ SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
# Per-install auth token for the localhost WS + HTTP API. Regenerated
# every backend start. Only code running as the current OS user (Electron
# main process, our Python MCP subprocesses, the Claude Code CLI we
# spawn) can read this file. Webpages loaded in any browser on the
# machine cannot — which is the whole point. See auth.py.
# Per-install auth token for the localhost API; see auth.py.
AUTH_TOKEN_FILE = os.path.join(DATA_ROOT, "auth.token")
BACKEND_DIR = _BACKEND_DIR
+35 -197
View File
@@ -9,13 +9,8 @@ logger = logging.getLogger(__name__)
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi import Request
# In-memory store for pending OAuth flows (state -> {provider, code_verifier, redirect_uri})
_pending_oauth: dict[str, dict] = {}
# Recently-completed OAuth states so the /api/subscriptions/callback handler
# can distinguish a legitimate duplicate callback (browser prefetch, refresh,
# or Google redirect retry after a slow first response) from a truly stale
# request. Bounded FIFO — drops the oldest entries once it grows past
# _MAX_COMPLETED_OAUTH so it can't leak memory.
# Bounded FIFO of recently-completed OAuth states; lets the callback distinguish duplicate hits (prefetch, refresh) from stale.
_completed_oauth: list[str] = []
_MAX_COMPLETED_OAUTH = 64
@@ -24,7 +19,6 @@ def _mark_oauth_completed(state: str) -> None:
if state in _completed_oauth:
return
_completed_oauth.append(state)
# Trim head if we've outgrown the bound
while len(_completed_oauth) > _MAX_COMPLETED_OAUTH:
_completed_oauth.pop(0)
from backend.config.Apps import MainApp
@@ -52,8 +46,7 @@ import json
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy, workflows])
app = main_app.app
# Generate per-install auth token BEFORE we bind the HTTP port. By the
# time any request lands, the token file exists. See backend/auth.py.
# Generate per-install auth token BEFORE we bind the HTTP port so the token file exists by the time any request lands.
from backend.auth import (
init_auth_token,
install_token_scrubber,
@@ -62,18 +55,11 @@ from backend.auth import (
is_origin_allowed,
)
init_auth_token()
# Install the log scrubber AFTER the token exists so any log line that
# accidentally embeds it (subprocess env dumps, urllib retry traces,
# proxied-request error bodies) gets redacted before hitting handlers.
# Install log scrubber AFTER token exists so any log line embedding it gets redacted before handlers see it.
install_token_scrubber()
# CORS: previously wide open (`allow_origins=["*"]`), which combined with
# `allow_credentials=True` was a security footgun — any external origin
# could CORS-preflight us. Now restricted to Electron renderer origins +
# localhost dev servers. The token middleware below provides the
# *primary* defense; CORS is defense-in-depth so a misconfigured page
# can't even reach us.
# CORS restricted to Electron renderer + localhost dev; token middleware below is the primary defense.
app.add_middleware(
CORSMiddleware,
allow_origins=[
@@ -86,49 +72,22 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# Every cross-origin POST from the Electron renderer (file:// → http://localhost:8324)
# carries Authorization: Bearer, which CORS classifies as non-simple and
# forces a preflight OPTIONS before EACH POST. With no max_age the browser
# re-preflights on a tight schedule (~5 s in Chromium); under heavy
# interaction we observed a 1:1 OPTIONS-to-POST ratio in the dev log,
# doubling roundtrip count for no reason. Caching the preflight result
# for 10 minutes drops that to one OPTIONS per ~600 POSTs.
# Cache preflight 10min; without this Chromium re-preflights every ~5s and we saw 1:1 OPTIONS:POST in dev logs.
max_age=600,
)
@app.middleware("http")
async def _auth_middleware(request: Request, call_next):
"""Reject HTTP requests without our per-install bearer token.
Exemptions (see `auth.is_path_exempt`):
- `/api/subscriptions/callback` external OAuth redirects
- `/api/health`, `/api/version` Electron boot handshake
- `OPTIONS` preflights browsers don't send Authorization on them
Anything else requires `Authorization: Bearer <token>` OR
`x-openswarm-token: <token>`. Failure responds with 401 and a short
JSON error no upstream handler sees the request.
The anthropic-proxy route (`/api/anthropic-proxy/v1/*`) is NOT
exempt. Its caller (the Claude Code CLI we spawn) is configured
with `ANTHROPIC_API_KEY=<our_token>` so the CLI's `x-api-key`
header carries our token which `request_matches_token` accepts
via its auth-header branches.
"""
# Preflights never carry Authorization.
"""Reject HTTP requests without our per-install bearer token."""
if request.method == "OPTIONS":
response = await call_next(request)
elif is_path_exempt(request.url.path):
response = await call_next(request)
else:
# Accept Authorization Bearer, x-openswarm-token, OR x-api-key
# (CLI path — CLI sends x-api-key with our token as value).
headers = dict(request.headers)
x_api_key = headers.get("x-api-key") or headers.get("X-API-Key")
# Accept `?token=<token>` query param too. Required for browser-driven
# GETs that can't set headers — notably the App Builder iframe loading
# /api/outputs/.../serve/index.html via <iframe src="...">.
# Accept ?token= for browser-driven GETs that can't set headers (App Builder iframe).
auth_ok = request_matches_token(headers, query_params=dict(request.query_params))
if not auth_ok and x_api_key:
import secrets as _s
@@ -145,29 +104,13 @@ async def _auth_middleware(request: Request, call_next):
)
response = await call_next(request)
# Private-Network-Access header for the one remaining public-origin
# path (OAuth callback). Harmless on other requests.
# PNA header for the OAuth callback (one remaining public-origin path); harmless elsewhere.
response.headers.setdefault("Access-Control-Allow-Private-Network", "true")
return response
@app.websocket("/ws/agents/{session_id}")
async def websocket_session(websocket: WebSocket, session_id: str):
"""Per-session WS endpoint with resume + heartbeat.
Resilience contract (see backend/apps/agents/seq_log.py):
- Every serverclient event carries a monotonic `seq` per session.
- On (re)connect the client sends `client:hello` with its
last-seen seq; the server replays missed events (or emits
`agent:gap_detected` if the gap is too large) and answers
with `server:hello` carrying the current high-water seq.
- `client:ping` `server:pong` heartbeat (default 25s) so
silent socket deaths (NAT idle drop, laptop sleep) are
detected without waiting for the next outbound frame.
- `WebSocketDisconnect` only removes the socket from the
connection registry. The agent task keeps running. The only
things that end a run are: natural completion, explicit
`agent:stop`, REST `/close`, or process shutdown.
"""
"""Per-session WS endpoint with resume + heartbeat (see seq_log.py for the contract)."""
if not _ws_auth_ok(websocket):
return
await ws_manager.connect_session(session_id, websocket)
@@ -179,12 +122,6 @@ async def websocket_session(websocket: WebSocket, session_id: str):
payload = msg.get("data", {})
if event == "client:hello":
# Resume handshake. The client sends this immediately
# after the WS opens, with `last_seq` = the highest
# seq it has applied. We replay anything newer; on
# first connect last_seq=0 and replay() correctly
# returns nothing (empty buffer) or the persisted
# terminal event for already-finished sessions.
last_seq = int(payload.get("last_seq") or 0)
connection_uuid = payload.get("connection_uuid") or ""
ack = await ws_manager.replay_to(session_id, websocket, last_seq)
@@ -199,10 +136,6 @@ async def websocket_session(websocket: WebSocket, session_id: str):
},
}))
elif event == "client:ping":
# Heartbeat. Cheap, keeps NATs/firewalls from
# silently dropping the connection. Carry the
# client's nonce back so it can match pong→ping for
# round-trip latency tracking if it wants.
await websocket.send_text(json.dumps({
"event": "server:pong",
"session_id": session_id,
@@ -236,16 +169,11 @@ async def websocket_session(websocket: WebSocket, session_id: str):
from backend.apps.agents.agent_manager import agent_manager
await agent_manager.stop_agent(session_id)
except WebSocketDisconnect:
# Drops the socket from the connection list. Does NOT cancel
# the agent task — that's intentional. See module docstring.
# Drops the socket but does NOT cancel the agent task; that's intentional.
ws_manager.disconnect_session(session_id, websocket)
def _ws_auth_ok(websocket: WebSocket) -> bool:
"""Validate token + origin before accepting a WS. Returns True if OK.
On failure closes with 4401 (custom app-level code) and returns False
the caller must NOT call `websocket.accept()` or read any data.
"""
"""Validate token + origin before accepting a WS; closes with 4401 on failure."""
headers = dict(websocket.headers)
qp = dict(websocket.query_params)
origin = headers.get("origin") or headers.get("Origin")
@@ -253,9 +181,8 @@ def _ws_auth_ok(websocket: WebSocket) -> bool:
origin_ok = is_origin_allowed(origin)
if not (token_ok and origin_ok):
reason = "bad token" if not token_ok else f"bad origin ({origin})"
logger.warning(f"ws: rejecting connection to {websocket.url.path} {reason}")
# Can't `await websocket.close()` before accept(), so schedule the
# close in a task. The client receives a 403 on handshake.
logger.warning(f"ws: rejecting connection to {websocket.url.path}: {reason}")
# Can't await close() before accept(); schedule it so the client sees a 403 on handshake.
import asyncio as _asyncio
_asyncio.create_task(websocket.close(code=4401))
return False
@@ -264,22 +191,14 @@ def _ws_auth_ok(websocket: WebSocket) -> bool:
@app.websocket("/ws/outputs/runtime/{workspace_id}/logs")
async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
"""Stream the persistent app-backend's stdout/stderr to the Terminal
pane. On connect we replay the runtime's ring buffer so a Terminal
tab opened mid-session sees the context it missed, then we tail
every subsequent line until disconnect."""
"""Stream the app-backend's stdout/stderr to the Terminal pane; replays ring buffer on connect, tails after."""
if not _ws_auth_ok(websocket):
return
await websocket.accept()
from backend.apps.outputs.runtime import manager as runtime_manager
rt = runtime_manager.get(workspace_id)
if rt is None:
# No active runtime — surface that to the client and close. The
# frontend will call /runtime/start and reconnect. Also emit a
# status frame with is_new_mode (computed from disk) so the
# preview pane shows the "starting preview…" placeholder for
# webapp_template workspaces instead of falling back to the
# legacy /serve/index.html URL (which 404s in new-mode).
# No runtime: emit is_new_mode status so webapp_template workspaces show the starting-preview placeholder, not the legacy 404ing serve URL.
try:
from backend.apps.outputs.outputs import _runtime_status_payload
status = _runtime_status_payload(workspace_id)
@@ -295,10 +214,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
finally:
await websocket.close()
return
# Buffer log lines from the synchronous subscriber callback into an
# asyncio.Queue we can `await` on the WS sender side. The subscribe
# call replays the ring buffer synchronously, so the queue gets
# primed with existing lines before we enter the loop.
# Bridge sync subscriber callback to async sender; subscribe replays the ring buffer synchronously, priming the queue.
queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
def _on_line(line) -> None:
@@ -324,11 +240,6 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
}
try:
# Initial status frame so the client knows port/running state
# without a second HTTP round-trip. `frontend_url` is the
# new-mode preview pointer (Vite dev server); `backend_url` is
# the workspace's optional FastAPI backend (old-mode backend.py
# OR new-mode post-backend_init.sh).
await websocket.send_text(json.dumps(_build_status_frame()))
while True:
stream, text = await queue.get()
@@ -337,12 +248,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
"workspace_id": workspace_id,
"data": {"stream": stream, "text": text},
}))
# Runtime-level events (start, frontend-ready, exit) flow
# through the same log channel with stream="runtime". When
# the client sees one, it usually wants the fresh status —
# bind-ready in particular flips frontend_url from null
# to the Vite URL and the preview pane has to know to
# switch over. Re-push status after every runtime line.
# Re-push status on runtime events: bind-ready flips frontend_url from null to the Vite URL and the preview pane needs to switch.
if stream == "runtime":
await websocket.send_text(json.dumps(_build_status_frame()))
except WebSocketDisconnect:
@@ -381,8 +287,7 @@ async def websocket_dashboard(websocket: WebSocket):
@app.post("/api/browser/command")
async def browser_command(request: Request):
"""HTTP endpoint called by the browser MCP server subprocess.
Proxies commands to the frontend via WebSocket and waits for results."""
"""Browser MCP subprocess endpoint; proxies commands to frontend over WS and waits for results."""
body = await request.json()
action = body.get("action", "")
browser_id = body.get("browser_id", "")
@@ -399,7 +304,7 @@ async def browser_command(request: Request):
@app.get("/api/subscriptions/pending/{state}")
async def subscriptions_pending(state: str):
"""Return pending OAuth data for a state param. Called by 9Router's callback page."""
"""Return pending OAuth data for a state param; called by 9Router's callback page."""
pending = _pending_oauth.get(state)
if not pending:
return JSONResponse({"error": "not found"}, status_code=404,
@@ -425,34 +330,18 @@ _SUCCESS_HTML = (
@app.get("/api/subscriptions/callback")
async def subscriptions_callback(request: Request):
"""Catch OAuth redirect from provider, exchange code via 9Router, close window.
Must be idempotent: the browser can legitimately hit this URL more than
once (Chrome prefetch, user refresh, Google retrying a slow first
redirect). The first call consumes `_pending_oauth[state]`, so a second
call would otherwise render a misleading "Session expired" even though
the connection is already saved. To handle that, we track recently-
completed state values in `_completed_oauth` and return the success
page whenever we see a duplicate.
"""
"""Catch OAuth redirect from provider, exchange code via 9Router, close window; idempotent against prefetch/refresh duplicates."""
code = request.query_params.get("code", "")
state = request.query_params.get("state", "")
error = request.query_params.get("error", "")
if error:
# Escape both inputs — `error_description` and `error` are attacker-
# controllable query params and the endpoint is auth-exempt, so an
# unescaped interpolation here is a reflected XSS in the localhost
# origin (loadable inside the Electron app context, where same-origin
# JS has access to the install token).
# Escape: error/error_description are attacker-controllable and this endpoint is auth-exempt, so raw interpolation is reflected XSS in the localhost origin.
desc = html.escape(request.query_params.get("error_description", error))
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Authorization failed</h2><p style="color:#888">{desc}</p></div></body></html>')
pending = _pending_oauth.pop(state, None)
if not pending:
# Either a duplicate callback for a state we've already exchanged,
# or a truly stale state. Duplicates are the expected case —
# Chrome's prefetcher and some extensions speculatively GET URLs.
if state and state in _completed_oauth:
logger.info(f"Duplicate OAuth callback for state {state[:8]}... (already completed)")
return HTMLResponse(_SUCCESS_HTML)
@@ -464,10 +353,7 @@ async def subscriptions_callback(request: Request):
await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state)
except Exception as e:
logger.warning(f"OAuth exchange failed for provider={pending.get('provider')}: {e}")
# Escape the exception message — upstream OAuth provider errors can
# echo back attacker-influenced strings (e.g. error_description from
# the original request URL), and this response is rendered in the
# localhost origin.
# Escape: upstream OAuth errors can echo attacker-influenced strings and this response renders in the localhost origin.
safe_e = html.escape(str(e))
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Connection failed</h2><p style="color:#888">{safe_e}</p></div></body></html>')
@@ -478,8 +364,7 @@ async def subscriptions_callback(request: Request):
@app.post("/api/browser-agent/run")
async def browser_agent_run(request: Request):
"""Run one or more browser sub-agents in parallel.
Called by the browser_agent_mcp_server stdio subprocess."""
"""Run one or more browser sub-agents in parallel; called by the browser_agent_mcp_server stdio subprocess."""
from backend.apps.settings.settings import load_settings
from backend.apps.agents.browser_agent import run_browser_agents
@@ -505,28 +390,14 @@ async def browser_agent_run(request: Request):
@app.post("/api/mcp-meta/{action}")
async def mcp_meta(action: str, request: Request):
"""Back the openswarm-mcp-meta stdio MCP server.
Actions:
- list: enumerate installed MCPs, separated by active vs available.
- search: rank by description match against a query.
- activate: append to session.active_mcps + flag needs_fork=True so the
next turn rebuilds options with the newly-activated server. Validates
server_name against the canonical registry; unknown names return the
valid options instead of activating (anti-hallucination).
"""
"""Back the openswarm-mcp-meta stdio MCP server: list, search, activate."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools, _sanitize_server_name
body = await request.json()
parent_session_id = body.get("parent_session_id", "")
# Aliases that broaden the search corpus for common user intents. Without
# these, MCPSearch("email") fails to surface Google Workspace because
# the tool's stored description says "Gmail" not "email". Keys are
# sanitized server names; values are extra search-hint tokens appended
# to the haystack. Only generic synonyms — anything that's already in
# the description doesn't need to be listed.
# Synonym aliases broaden the search corpus so MCPSearch("email") surfaces Google Workspace despite its description saying "Gmail".
_SERVER_SEARCH_ALIASES: dict[str, list[str]] = {
"google-workspace": [
"email", "inbox", "mail", "gmail", "calendar", "schedule",
@@ -553,8 +424,7 @@ async def mcp_meta(action: str, request: Request):
if not (t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")):
continue
sanitized = _sanitize_server_name(t.name)
# Pull tool sub-action names from tool_permissions._tool_descriptions
# so MCPSearch can match against capability names (e.g. "send_email").
# Pull sub-action names so MCPSearch can match against capability names like "send_email".
action_names: list[str] = []
try:
td = (t.tool_permissions or {}).get("_tool_descriptions", {})
@@ -587,11 +457,7 @@ async def mcp_meta(action: str, request: Request):
servers = _connected_servers()
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
active_set = set(session.active_mcps) if session else set()
# Ranking: substring hits across name+description+sub-tool names+
# generic-purpose aliases. The aliases are what let "email" match
# google-workspace even though the description says "Gmail".
# Active-first tiebreak so the model prefers servers it has already
# activated when both score equally.
# Substring rank across name+desc+sub-tools+aliases; active-first tiebreak.
scored: list[tuple[int, dict]] = []
for s in servers:
extras = s.get("_search_extras", "")
@@ -599,9 +465,7 @@ async def mcp_meta(action: str, request: Request):
score = 0
for tok in query.split():
if tok and tok in hay:
# Hits in the canonical name count more; alias hits
# count once so a "drive" query doesn't beat the actual
# Drive tool description.
# Canonical name hits weight 2; alias hits 1 so "drive" doesn't beat the actual Drive description.
if tok in s["name"]:
score += 2
elif tok in s["description"].lower():
@@ -636,14 +500,7 @@ async def mcp_meta(action: str, request: Request):
session.active_mcps.append(server_name)
session.needs_fork = True
# When the session has prior turns, fork_session alone won't
# make the bundled CLI re-read mcp_servers — the transport
# snapshot at launch time is what serves tool schemas. Force a
# full fresh-session restart so the next turn rebuilds with the
# newly-activated server in its mcp_servers dict from scratch.
# First-turn activations don't need this (the SDK session hasn't
# locked in yet). One-time ~200-400ms cold start on the auto-
# continuation turn that fires right after this anyway.
# Mid-session activations need a fresh-session restart: the CLI snapshots mcp_servers at launch, so fork_session alone won't re-read schemas.
if session.sdk_session_id:
session.needs_fresh_session = True
try:
@@ -655,21 +512,13 @@ async def mcp_meta(action: str, request: Request):
})
except Exception:
logger.exception("Failed to broadcast post-activate session status")
pass # MCP activation captured via session dump on close
# Auto-continue: flag the session so that after its current turn
# ends (which is the turn that contains this MCPActivate tool
# call), the agent loop dispatches a synthetic "continue" turn
# with the freshly-activated tools available. Race-free — read
# at the natural turn-boundary inside _run_agent_loop instead of
# racing a background task against the turn's completion path.
# Turns the typical 3-prompt flow ("check email" → MCPActivate
# → "do it") into a 1-prompt flow.
# Auto-continue: read at turn boundary in _run_agent_loop (race-free); collapses the typical 3-prompt flow into 1.
session.pending_continuation = True
session.pending_continuation_prompt = (
"[mcp:auto-continue] The MCP server you requested has been "
f"activated (`{server_name}`). Continue with the user's original "
"request now using the newly-available tools do NOT ask "
"request now using the newly-available tools; do NOT ask "
"for confirmation."
)
@@ -680,12 +529,7 @@ async def mcp_meta(action: str, request: Request):
@app.post("/api/agents/sessions/{session_id}/compact")
async def session_compact(session_id: str):
"""Force a compaction pass on a session (Phase 2 /compact slash cmd).
Cheap programmatic summarization (no aux LLM call), so it's safe to
invoke at any time. Sets needs_fork=True so the next turn rebuilds
options and ships the compacted prefix.
"""
"""Force a compaction pass on a session (/compact slash cmd); programmatic summary, no aux LLM call."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.ws_manager import ws_manager as _ws
session = agent_manager.sessions.get(session_id)
@@ -703,12 +547,7 @@ async def session_compact(session_id: str):
@app.post("/api/agents/sessions/{session_id}/clear")
async def session_clear(session_id: str):
"""Reset a session to a fresh sdk_session_id (Phase 2 /clear slash cmd).
Preserves session.messages (so the chat UI keeps the visible history)
but clears the SDK-side conversation by minting a new sdk_session_id.
Also drops active_mcps so the user starts fresh.
"""
"""Reset a session to a fresh sdk_session_id (/clear); preserves UI history, drops SDK convo and active_mcps."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.ws_manager import ws_manager as _ws
session = agent_manager.sessions.get(session_id)
@@ -734,8 +573,7 @@ async def session_clear(session_id: str):
@app.post("/api/invoke-agent/run")
async def invoke_agent_run(request: Request):
"""Fork an existing agent session and send it a new message.
Called by the invoke_agent_mcp_server stdio subprocess."""
"""Fork an existing agent session and send a new message; called by invoke_agent_mcp_server."""
body = await request.json()
session_id = body.get("session_id", "")
message = body.get("message", "")
@@ -778,7 +616,7 @@ if __name__ == "__main__":
import uvicorn.config
class _ReadyServer(uvicorn.Server):
"""Subclass that prints a machine-readable READY line on startup."""
"""Prints a machine-readable READY line on startup."""
async def startup(self, sockets=None):
await super().startup(sockets)
print(f"READY:PORT={args.port}", flush=True)
+4 -4
View File
@@ -1,6 +1,6 @@
"""Smoketests for the desktop-side auth subapp.
These tests don't hit the real cloud they patch httpx so we can simulate
These tests don't hit the real cloud; they patch httpx so we can simulate
each cloud response and assert the local persistence + identify-status
logic is right across every gate-dismissal path the renderer cares about.
"""
@@ -80,7 +80,7 @@ def test_signin_activate_persists_user_id(client, reset_settings):
def test_signin_activate_paid_user_flips_pro_mode(client, reset_settings):
"""A signed-in user who already has a Stripe subscription should also
flip into openswarm-pro routing covers the Google-then-Stripe and
flip into openswarm-pro routing; covers the Google-then-Stripe and
Stripe-then-Google merge cases."""
fake_response = AsyncMock()
fake_response.status_code = 200
@@ -127,7 +127,7 @@ def test_signin_activate_invalid_token_returns_401(client, reset_settings):
def test_signin_activate_short_token_rejected_locally(client, reset_settings):
"""Short tokens rejected before we even hit the cloud saves a round trip."""
"""Short tokens rejected before we even hit the cloud; saves a round trip."""
r = client.post(
"/api/auth/signin-activate",
json={"token": "short", "signin_method": "google"},
@@ -136,7 +136,7 @@ def test_signin_activate_short_token_rejected_locally(client, reset_settings):
# ---------------------------------------------------------------------------
# /api/auth/identity-status gate-state for the renderer
# /api/auth/identity-status; gate-state for the renderer
# ---------------------------------------------------------------------------
def test_identity_status_signed_in_user_returns_authed_true(client, reset_settings):
+8 -8
View File
@@ -9,7 +9,7 @@ terminal state. After these fixes the contract should be:
2. Every event the server emits is replayable, in order, with no
duplicates and no gaps, after any number of disconnects.
3. Terminal events (completed/stopped/error) are always observable
by a client that reconnects later even if the only persistence
by a client that reconnects later; even if the only persistence
of the event is the on-disk terminal log.
4. Concurrent broadcasts (thinking deltas + tool calls + status
changes from many tasks) preserve seq order == wire order.
@@ -82,7 +82,7 @@ def _patch_persist_dir():
def _build_app(seq_log):
"""Replicates main.py's WS handler + adds a /test/emit endpoint
so the test thread can drive event emission through the same
event loop as the WS handler avoiding the cross-loop hazards
event loop as the WS handler; avoiding the cross-loop hazards
of `asyncio.run()` mid-test."""
from backend.apps.agents.ws_manager import ws_manager
@@ -159,7 +159,7 @@ async def _emit_run(session_id: str, n_events: int, terminate: str | None = "com
"message_id": "m1",
"delta": f"chunk-{start + i}",
})
# Yield to the scheduler so other coroutines interleave
# Yield to the scheduler so other coroutines interleave ,
# this is what surfaces the seq race if locking is wrong.
await asyncio.sleep(0)
@@ -275,7 +275,7 @@ def test_resume_after_disconnect_recovers_all_events(_patch_persist_dir):
def test_terminal_event_visible_after_full_eviction(_patch_persist_dir):
"""If the in-memory log is wiped (process restart simulation),
a reconnecting client should still see the terminal event from
disk never a phantom 'running' spinner."""
disk; never a phantom 'running' spinner."""
app = _build_app(_patch_persist_dir)
sid = "session-evict-term-1"
@@ -449,7 +449,7 @@ def test_concurrent_broadcast_preserves_order(trial, _patch_persist_dir):
# ---------------------------------------------------------------------------
# Auth/security smoke: the WS endpoint here is unauth'd by design (test
# scaffolding) but main.py's _ws_auth_ok must remain in place. This
# scaffolding); but main.py's _ws_auth_ok must remain in place. This
# test pins that contract so a future refactor can't accidentally
# strip it.
# ---------------------------------------------------------------------------
@@ -485,7 +485,7 @@ def test_terminate_during_disconnect_is_observable(trial, _patch_persist_dir):
# Disconnected. Emit the rest + terminate while WS is gone.
_emit(client, sid, n=n_post, terminate="completed")
# Reconnect. We expect to receive everything from last_seq+1
# through to the terminal possibly via disk if the buffer
# through to the terminal; possibly via disk if the buffer
# rolled (it won't here; numbers are small).
with client.websocket_connect(f"/ws/agents/{sid}") as ws:
ws.send_text(json.dumps({"event": "client:hello", "data": {"last_seq": last_seq, "connection_uuid": "c2"}}))
@@ -534,10 +534,10 @@ def test_main_ws_endpoints_still_gated_by_auth(_patch_persist_dir):
src = open(os.path.join(os.path.dirname(__file__), "..", "main.py")).read()
assert "_ws_auth_ok(websocket)" in src, (
"main.py WS endpoints must still call _ws_auth_ok before accepting "
"the connection otherwise any local web page can read agent traffic."
"the connection; otherwise any local web page can read agent traffic."
)
# And the disconnect handler must NOT call any task-cancel helper
# that's the regression we're guarding against.
#; that's the regression we're guarding against.
assert "stop_agent" not in src.split("WebSocketDisconnect")[1].split("def ")[0], (
"WebSocketDisconnect handler must not cancel the agent task."
)
+7 -7
View File
@@ -28,7 +28,7 @@ os.environ.setdefault("OPENSWARM_DATA_DIR", _TMPROOT)
# ---------------------------------------------------------------------------
# Group 1 Message.client_message_id
# Group 1; Message.client_message_id
# ---------------------------------------------------------------------------
@@ -51,7 +51,7 @@ def test_message_round_trips_client_id():
def test_message_legacy_payload_without_client_id():
"""Older session JSON files won't have the field must still load."""
"""Older session JSON files won't have the field; must still load."""
from backend.apps.agents.models import Message
legacy = {
@@ -82,7 +82,7 @@ def test_client_message_id_collision_resistance():
# ---------------------------------------------------------------------------
# Group 2 Mode migration: chat → ask
# Group 2; Mode migration: chat → ask
# ---------------------------------------------------------------------------
@@ -196,7 +196,7 @@ def test_reconcile_idempotent():
# ---------------------------------------------------------------------------
# Group 6 Notes layout serialization
# Group 6; Notes layout serialization
# ---------------------------------------------------------------------------
@@ -254,11 +254,11 @@ def test_notes_stress_many_round_trips():
# ---------------------------------------------------------------------------
# Group 7 Concurrent send_message dedupe stress
# Group 7; Concurrent send_message dedupe stress
#
# Real-world scenario: user mashes Enter quickly. 50 concurrent sends
# each with a unique client_message_id must produce 50 echoed messages
# carrying the right ids. Pure pydantic / asyncio test no real
# carrying the right ids. Pure pydantic / asyncio test; no real
# agent loop.
# ---------------------------------------------------------------------------
@@ -266,7 +266,7 @@ def test_notes_stress_many_round_trips():
@pytest.mark.asyncio
async def test_concurrent_send_message_unique_client_ids():
"""100 parallel Message constructions with unique client_message_ids
must round-trip independently no cross-talk on the dataclass."""
must round-trip independently; no cross-talk on the dataclass."""
from backend.apps.agents.models import Message
async def make_one(i: int):
+3 -3
View File
@@ -45,7 +45,7 @@ def install_sync_sink():
cs = body.get("client_state") or {}
payload = body.get("d") or body.get("payload") or {}
# Infer a synthetic kind from payload shape same dispatch logic
# Infer a synthetic kind from payload shape; same dispatch logic
# as the cloud uses in production.
if "status" in payload and "messages" in payload:
status = payload.get("status", "unknown")
@@ -147,7 +147,7 @@ def manager():
# ---------------------------------------------------------------------------
# 1. record() legacy shim correctness
# 1. record(); legacy shim correctness
# ---------------------------------------------------------------------------
class TestRecordBasics:
@@ -175,7 +175,7 @@ class TestRecordBasics:
# ---------------------------------------------------------------------------
# 2. Multi-message session close fires exactly once
# 2. Multi-message session; close fires exactly once
# ---------------------------------------------------------------------------
class TestMultiMessageSession:
+29 -193
View File
@@ -36,10 +36,6 @@ _TMPROOT = tempfile.mkdtemp(prefix="openswarm-v2-invariants-")
os.environ.setdefault("OPENSWARM_DATA_DIR", _TMPROOT)
# ---------------------------------------------------------------------------
# Fixture: build a fake ToolDefinition without touching disk.
# ---------------------------------------------------------------------------
def _fake_tool(
name: str,
*,
@@ -60,13 +56,10 @@ def _fake_tool(
)
# ===========================================================================
# Group A — MCP activation gate (the non-bypassable ToolSearch invariant)
# ===========================================================================
# The product invariant: NO MCP tool is callable until the model has
# explicitly searched + activated the server, and the user has approved
# the activation. The gate lives at the dispatch layer in
# `_build_mcp_servers` even if the prompt rules are ignored, the SDK
# `_build_mcp_servers`; even if the prompt rules are ignored, the SDK
# never sees the unactivated server.
@@ -82,7 +75,6 @@ async def test_gate_blocks_when_active_mcps_empty():
with patch("backend.apps.agents.agent_manager.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.agent_manager.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
# allowed_tools includes mcp:Gmail, but active_mcps is empty
result = await mgr._build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"],
active_mcps=[],
@@ -185,11 +177,9 @@ async def test_gate_stress_random_activations():
fake_tools = [_fake_tool(raw_names[i]) for i in connected_idx]
connected_sanitized = [server_pool[i] for i in connected_idx]
# active set is a random subset of connected
active_n = random.randint(0, len(connected_sanitized))
active = random.sample(connected_sanitized, active_n)
# allowed_tools mirrors raw names of connected
allowed = [f"mcp:{raw_names[i]}" for i in connected_idx]
with patch("backend.apps.agents.agent_manager.load_all_tools", return_value=fake_tools), \
@@ -202,7 +192,6 @@ async def test_gate_stress_random_activations():
active_mcps=active,
)
keys = set(result.keys())
# MUST: keys ⊆ active ∩ connected
allowed_set = set(active) & set(connected_sanitized)
assert keys.issubset(allowed_set), (
f"GATE BREACH: {keys - allowed_set} leaked through "
@@ -210,14 +199,6 @@ async def test_gate_stress_random_activations():
)
# ===========================================================================
# Group B — needs_fresh_session soft-restart
# ===========================================================================
# When MCPActivate fires mid-session, the bundled CLI doesn't re-read
# mcp_servers from a fork. We force a fresh sdk_session_id so the new
# server's tools actually reach the model.
def test_needs_fresh_session_field_default_false():
"""Brand-new sessions must default needs_fresh_session=False."""
from backend.apps.agents.models import AgentSession
@@ -239,7 +220,7 @@ def test_needs_fresh_session_serializes_round_trip():
def test_legacy_session_json_loads_without_field():
"""Old session JSONs predate the field Pydantic must fill in default."""
"""Old session JSONs predate the field; Pydantic must fill in default."""
from backend.apps.agents.models import AgentSession
legacy = {
"id": "old", "name": "legacy", "model": "sonnet", "mode": "agent",
@@ -247,7 +228,6 @@ def test_legacy_session_json_loads_without_field():
}
s = AgentSession.model_validate(legacy)
assert s.needs_fresh_session is False
# extras silently absorbed → can't be a regression hazard
legacy_with_ghost = {**legacy, "answer_tokens": 999, "thought_signature": "abc=="}
s2 = AgentSession.model_validate(legacy_with_ghost)
assert s2.id == "old"
@@ -256,10 +236,8 @@ def test_legacy_session_json_loads_without_field():
def test_mcp_activate_sets_fresh_session_when_history_exists():
"""The gate logic at main.py: if sdk_session_id exists, set needs_fresh_session=True."""
from backend.apps.agents.models import AgentSession
# Mid-session: sdk already locked in
s = AgentSession(id="mid", name="t", model="sonnet", mode="agent")
s.sdk_session_id = "claude-session-existing"
# Simulate the gate handler logic
if s.sdk_session_id:
s.needs_fresh_session = True
assert s.needs_fresh_session is True
@@ -269,7 +247,6 @@ def test_mcp_activate_skips_fresh_session_on_first_turn():
"""First-turn activation: no sdk_session_id yet, so needs_fresh_session stays False."""
from backend.apps.agents.models import AgentSession
s = AgentSession(id="fresh", name="t", model="sonnet", mode="agent")
# No sdk_session_id yet
if s.sdk_session_id:
s.needs_fresh_session = True
assert s.needs_fresh_session is False
@@ -285,9 +262,6 @@ def test_active_mcps_append_idempotent():
assert s.active_mcps.count("gmail") == 1
# ===========================================================================
# Group C — Pydantic Message backward compat (no ghost fields, legacy loads)
# ===========================================================================
def test_message_no_ghost_fields():
@@ -300,7 +274,7 @@ def test_message_no_ghost_fields():
def test_message_legacy_payload_with_ghost_fields_still_loads():
"""Old session JSONs may carry the deleted fields Pydantic must ignore them."""
"""Old session JSONs may carry the deleted fields; Pydantic must ignore them."""
from backend.apps.agents.models import Message
legacy = {
"id": "m1",
@@ -312,10 +286,8 @@ def test_message_legacy_payload_with_ghost_fields_still_loads():
"input_tokens": 1234,
}
m = Message.model_validate(legacy)
# Fields that survived are preserved
assert m.tool_count == 3
assert m.input_tokens == 1234
# Ghost fields don't blow up + don't leak into re-dump
redumped = m.model_dump(mode="json")
assert "answer_tokens" not in redumped
assert "thought_signature" not in redumped
@@ -358,9 +330,6 @@ def test_message_round_trip_50_iterations():
assert m2.elapsed_ms == m.elapsed_ms
# ===========================================================================
# Group D — resolve_aux_model Gemini route (the gemini-3.1-flash-lite-preview fix)
# ===========================================================================
@pytest.mark.asyncio
@@ -464,11 +433,10 @@ async def test_resolve_aux_model_openrouter_primary_prefers_or():
@pytest.mark.asyncio
async def test_resolve_aux_model_openrouter_priority_after_subs():
"""In the default cascade (no primary_api), Claude/Codex/Gemini subs
win over OR OR is metered while subs are sub-covered free."""
win over OR; OR is metered while subs are sub-covered free."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
# Both Codex and OR connected — Codex (free via sub) should win.
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[
@@ -479,14 +447,6 @@ async def test_resolve_aux_model_openrouter_priority_after_subs():
assert model_id == "cx/gpt-5.4-mini", f"got {model_id}"
# ===========================================================================
# Group E — 9Router-streamed 401 detection
# ===========================================================================
# 9Router sometimes returns upstream auth failures AS the assistant's
# reply text, not as an exception. We detect the pattern in the stream
# handler to substitute a friendly bubble.
def test_router_auth_pattern_codex():
"""The pattern detector at agent_manager.py:2841-2846."""
text = (
@@ -520,7 +480,7 @@ def test_router_auth_pattern_does_not_falsely_match_normal_text():
"Here are your recent emails: ...",
"I found 3 results for your search.",
"Sorry, I don't have access to that file.",
"401 Unauthorized wait this is a code example I'm explaining", # tricky
"401 Unauthorized; wait this is a code example I'm explaining", # tricky
]
for text in benign_replies:
lower = text.lower()
@@ -537,7 +497,6 @@ def test_is_auth_error_classifier():
"""The classifier at agent_manager.py:_is_auth_error covers many shapes."""
from backend.apps.agents.agent_manager import _is_auth_error
# Real shapes that must be caught
matches = [
Exception("Error 401: invalid_api_key"),
Exception("Got 403 from upstream"),
@@ -550,7 +509,6 @@ def test_is_auth_error_classifier():
for e in matches:
assert _is_auth_error(e), f"should match: {e}"
# Non-auth errors must not match
non_matches = [
Exception("Connection timeout"),
Exception("Rate limit exceeded"),
@@ -569,14 +527,6 @@ def test_is_auth_error_with_stderr_tail():
assert _is_auth_error(e, extra_text=stderr)
# ===========================================================================
# Group F — MCP_SERVER_BRAND coverage
# ===========================================================================
# Every server slug we surface to the user via MCPSearch / connected_servers
# should have a brand entry, otherwise the UI falls back to the kebab-case
# id ("microsoft-365" instead of "Microsoft 365").
def test_mcp_brand_covers_curated_servers():
"""Every curated server slug must already be in canonical sanitized form."""
curated = {
@@ -627,19 +577,13 @@ def test_sanitize_server_name_strips_special_chars():
assert _sanitize_server_name("a__b") == "a-b"
# ===========================================================================
# Group G — mcp_meta_server activation backend handler
# ===========================================================================
def test_mcp_activate_handler_unknown_server():
"""Unknown server name → status='unknown_server' with the valid list."""
# We test the response shape independently of the FastAPI plumbing.
# The handler is a closure inside main.py:mcp_meta_handler, so we
# instead exercise the contract: invalid name surfaces alternatives.
from backend.apps.tools_lib.tools_lib import _sanitize_server_name
valid = {"gmail", "slack", "google-workspace"}
requested = "Gmail" # raw, needs sanitize
requested = "Gmail"
sanitized = _sanitize_server_name(requested)
if sanitized in valid:
status = "would_activate"
@@ -649,7 +593,7 @@ def test_mcp_activate_handler_unknown_server():
def test_active_mcps_persistence_on_session():
"""active_mcps survives session.model_dump() round-trip critical for resume."""
"""active_mcps survives session.model_dump() round-trip; critical for resume."""
from backend.apps.agents.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.active_mcps = ["gmail", "slack"]
@@ -658,9 +602,6 @@ def test_active_mcps_persistence_on_session():
assert rehydrated.active_mcps == ["gmail", "slack"]
# ===========================================================================
# Group H — long-context error classifier
# ===========================================================================
def test_long_context_pattern_caught():
@@ -690,10 +631,7 @@ def test_transient_capacity_patterns():
]
for t in transients:
assert _TRANSIENT_CAPACITY_PATTERNS.search(t), f"transient missed: {t!r}"
# Importantly: must NOT also match non-transient (no double-classification)
# except for the fuzzy edge cases. Spot-check a couple:
if "429" in t and "rate_limit" in t.lower():
# rate_limit_error is transient; non-transient should not match this exact text
assert not _NON_TRANSIENT_PATTERNS.search(t)
@@ -703,9 +641,6 @@ def test_long_context_does_not_match_normal_429():
assert not _NON_TRANSIENT_PATTERNS.search("Error 429: rate_limit_error")
# ===========================================================================
# Group I — Mode reconciliation (regression guard)
# ===========================================================================
def test_chat_mode_not_in_builtins():
@@ -726,9 +661,6 @@ def test_active_mcps_default_factory_creates_new_list():
assert s2.active_mcps == [], "active_mcps must not share state across sessions"
# ===========================================================================
# Group J — Concurrent gate stress (real production risk: simultaneous turns)
# ===========================================================================
@pytest.mark.asyncio
@@ -752,9 +684,6 @@ async def test_concurrent_gate_calls_isolated():
assert set(empty.keys()) == set()
# ===========================================================================
# Group K — pending_continuation auto-restart
# ===========================================================================
def test_pending_continuation_default_false():
@@ -776,7 +705,7 @@ def test_pending_continuation_serializes():
def test_compact_threshold_default():
"""compact_threshold_pct default of 0.65 drift here breaks Phase 2 compaction."""
"""compact_threshold_pct default of 0.65; drift here breaks Phase 2 compaction."""
from backend.apps.agents.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
assert s.compact_threshold_pct == 0.65
@@ -784,13 +713,6 @@ def test_compact_threshold_default():
assert s.context_window == 200_000
# ===========================================================================
# Group L — Sentence-case display (the parseMcpToolName fix)
# ===========================================================================
# This is technically a frontend behavior, but we mirror the rule in
# Python so the backend's MCPSearch results don't leak Title Case either.
def test_sentence_case_rule():
"""Mirror of the JS _humanizeName: first word capitalized, rest lower."""
def sentence_case(name: str) -> str:
@@ -807,9 +729,6 @@ def test_sentence_case_rule():
assert sentence_case(raw) == expected
# ===========================================================================
# Group M — Bash command verb extraction (frontend logic, mirrored)
# ===========================================================================
def test_bash_verb_extraction_strips_env_prefix():
@@ -848,9 +767,6 @@ def test_bash_command_detail_path_basename():
assert basename(raw) == expected
# ===========================================================================
# Group N — Pydantic AppSettings invariants
# ===========================================================================
def test_app_settings_defaults():
@@ -874,9 +790,6 @@ def test_custom_provider_round_trip():
assert s2.custom_providers[0].name == "MyCorp"
# ===========================================================================
# Group O — Tool gate stress with denied permissions
# ===========================================================================
@pytest.mark.asyncio
@@ -887,7 +800,6 @@ async def test_gate_partially_denied_tool_blocked():
"_tool_descriptions": {"send_email": "Send email"},
"send_email": "deny",
})
# Build a minimal class that has the perms_dict shape _is_fully_denied expects
assert _is_fully_denied(fake) in (True, False)
@@ -903,13 +815,9 @@ async def test_gate_handles_missing_refresh_token_gracefully():
allowed_tools=["mcp:MyApiTool"],
active_mcps=["myapitool"],
)
# It should be present (configured + activated + not denied)
assert "myapitool" in result
# ===========================================================================
# Group P — resolve_aux_model failover logic
# ===========================================================================
@pytest.mark.asyncio
@@ -922,10 +830,9 @@ async def test_aux_failover_anthropic_to_codex():
settings.openswarm_proxy_url = "https://api.openswarm.test"
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[])): # nothing connected
# primary_api=codex but codex not connected → cascade to Pro/anthropic
new=AsyncMock(return_value=[])):
model_id, base = await registry.resolve_aux_model(settings, primary_api="codex")
assert "haiku" in model_id # fallthrough hit Anthropic Pro path
assert "haiku" in model_id
assert base == "https://api.openswarm.test"
@@ -953,14 +860,10 @@ async def test_aux_returns_sonnet_when_preferred_tier_set():
assert "sonnet" in model_id
# ===========================================================================
# Group Q — get_api_type / model id resolution
# ===========================================================================
def test_get_api_type_openai():
from backend.apps.agents.providers.registry import get_api_type
# gpt-5.4 maps to codex (the OpenAI-via-Codex-subscription api family)
api = get_api_type("gpt-5.4")
assert api in ("openai", "codex"), f"unexpected: {api}"
@@ -977,9 +880,6 @@ def test_find_builtin_model_returns_dict_for_known():
assert sonnet.get("api") == "anthropic"
# ===========================================================================
# Group R — context window
# ===========================================================================
def test_get_context_window_known_model():
@@ -994,11 +894,6 @@ def test_get_context_window_unknown_returns_default():
assert cw == 128_000
# ---------------------------------------------------------------------------
# Custom OpenAI-compatible providers (Ollama Cloud, Together, etc.)
# ---------------------------------------------------------------------------
def test_custom_provider_value_synthesises_route_api_entry():
"""`custom/<slug>/<bare>` picker values must synthesise a route='api',
api='custom' entry whose model_id is the 9Router routing string
@@ -1044,7 +939,6 @@ def test_custom_provider_lookup_finds_entry_by_slug():
assert cp is not None and cp.name == "Ollama Cloud"
cp2 = _find_custom_provider_for_value(s, "custom/together-ai/meta-llama/llama-3-70b")
assert cp2 is not None and cp2.name == "Together AI"
# Unknown slug → None.
assert _find_custom_provider_for_value(s, "custom/nonexistent/whatever") is None
@@ -1066,7 +960,7 @@ def test_get_context_window_custom_provider_value_format():
def test_custom_provider_slug_is_url_safe():
"""The slug must be alnum-and-dash only it's used both as the 9Router
"""The slug must be alnum-and-dash only; it's used both as the 9Router
prefix and as a URL path segment. Spaces, slashes, and special chars
must all be folded to dashes."""
from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup
@@ -1079,11 +973,8 @@ def test_custom_provider_slug_is_url_safe():
def test_custom_provider_slug_unicode_collapses_safely():
"""Unicode names are folded to ASCII-safe dashes; emojis/accents drop."""
from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup
# Accented chars get stripped (regex is [a-zA-Z0-9-] only).
assert _custom_provider_slug_for_lookup("Tögether AI 🚀") == "t-gether-ai"
# Pure-emoji name → fallback "custom".
assert _custom_provider_slug_for_lookup("🚀💎") == "custom"
# Trailing/leading dashes get stripped.
assert _custom_provider_slug_for_lookup("---weird---") == "weird"
@@ -1097,7 +988,6 @@ def test_custom_provider_slug_does_not_collide_with_routing_prefixes():
assert entry is not None
routed = entry["model_id"]
assert routed == "cp-cc/whatever"
# cp-cc is NOT cc/ — startswith check would have to match the exact slash.
assert not routed.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/", "openrouter/"))
@@ -1117,7 +1007,6 @@ def test_custom_provider_models_with_special_chars():
for v in cases:
e = _find_builtin_model(v)
assert e is not None, f"failed: {v}"
# Bare-model portion is everything after first slash after the slug.
rest = v[len("custom/"):]
slug, _, bare = rest.partition("/")
assert e["model_id"] == f"cp-{slug}/{bare}", f"bad routing for {v}: {e['model_id']}"
@@ -1125,7 +1014,7 @@ def test_custom_provider_models_with_special_chars():
def test_custom_provider_value_with_invalid_format_returns_none():
"""Malformed picker values (no slug, no model) must not synthesise a
bogus entry they should miss _find_builtin_model entirely so the
bogus entry; they should miss _find_builtin_model entirely so the
dispatch loop falls through to the 'unknown model' branch."""
from backend.apps.agents.providers.registry import _find_builtin_model
assert _find_builtin_model("custom/") is None
@@ -1134,7 +1023,7 @@ def test_custom_provider_value_with_invalid_format_returns_none():
def test_custom_provider_get_api_type_returns_custom():
"""get_api_type drives the dispatch branch in agent_manager.py must
"""get_api_type drives the dispatch branch in agent_manager.py; must
return 'custom' (not 'anthropic' default fallback) for a custom value."""
from backend.apps.agents.providers.registry import get_api_type
assert get_api_type("custom/ollama/gpt-oss:120b") == "custom"
@@ -1188,7 +1077,7 @@ def test_custom_provider_get_anthropic_client_routes_cp_to_9router():
def test_custom_provider_two_providers_get_distinct_slugs():
"""Two custom providers with different display names must produce
two different slugs / routing prefixes otherwise 9Router will route
two different slugs / routing prefixes; otherwise 9Router will route
both to whichever connection was created last."""
from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup
a = _custom_provider_slug_for_lookup("Ollama Cloud")
@@ -1203,7 +1092,7 @@ def test_custom_provider_slug_collision_after_sanitize():
The dedupe-by-name UI check guards against same-string entries; this
test just documents that post-slug collisions DO collide and the
UI-level uniqueness check (in Settings.tsx) is the right enforcement
layer backend resolution would always pick the first match."""
layer; backend resolution would always pick the first match."""
from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup
assert _custom_provider_slug_for_lookup("Ollama Cloud") == \
_custom_provider_slug_for_lookup("ollama-cloud") == \
@@ -1221,22 +1110,18 @@ def test_list_models_includes_complete_custom_providers_excludes_incomplete():
from unittest.mock import patch
cfg = AppSettings(custom_providers=[
# Complete — should appear.
CustomProvider(
name="Ollama Cloud", base_url="https://ollama.com/v1", api_key="x",
models=[{"value": "gpt-oss:120b", "label": "gpt-oss:120b"}],
),
# Empty base_url — should NOT appear.
CustomProvider(
name="Broken", base_url="", api_key="y",
models=[{"value": "model-a", "label": "model-a"}],
),
# No models — should NOT appear.
CustomProvider(
name="Empty", base_url="https://example.com/v1", api_key="z",
models=[],
),
# Empty name — should NOT appear.
CustomProvider(
name="", base_url="https://example.com/v1", api_key="z",
models=[{"value": "x", "label": "x"}],
@@ -1252,7 +1137,6 @@ def test_list_models_includes_complete_custom_providers_excludes_incomplete():
assert len(groups["Ollama Cloud"]) == 1
assert groups["Ollama Cloud"][0]["value"] == "custom/ollama-cloud/gpt-oss:120b"
assert groups["Ollama Cloud"][0]["billing_kind"] == "api_key"
# None of the incomplete entries' names create a group.
assert "Broken" not in groups
assert "Empty" not in groups
@@ -1319,7 +1203,7 @@ def test_custom_provider_context_window_falls_back_to_default():
def test_custom_provider_resolve_aux_model_unaffected():
"""resolve_aux_model is the one-shot LLM call path. Custom providers
are NOT in its decision tree Haiku/9Router/OR fallbacks should still
are NOT in its decision tree; Haiku/9Router/OR fallbacks should still
fire. Custom providers are deliberately not used for aux because we
don't know if they support tool calling well enough."""
import asyncio
@@ -1329,7 +1213,6 @@ def test_custom_provider_resolve_aux_model_unaffected():
anthropic_api_key="sk-ant-test",
custom_providers=[CustomProvider(name="Foo", base_url="https://x/v1", api_key="k")],
)
# Should pick Anthropic Haiku, not anything custom.
rid, base = asyncio.run(resolve_aux_model(s, preferred_tier="haiku"))
assert "haiku" in rid.lower()
assert not rid.startswith("cp-")
@@ -1347,20 +1230,15 @@ def test_custom_provider_with_very_long_name_still_works():
assert entry["model_id"] == f"cp-{slug}/some-model"
# ===========================================================================
# 9Router sync stress tests — async, mocked HTTP layer
# ===========================================================================
def _make_mock_9router(initial_nodes=None, initial_conns=None, fail_endpoints=None):
"""Build a mock httpx.AsyncClient that simulates 9Router's HTTP API.
Tracks state across requests so we can assert idempotency.
Returns (mock_client_class, state_dict) state_dict is mutated by calls."""
Returns (mock_client_class, state_dict); state_dict is mutated by calls."""
from unittest.mock import AsyncMock, MagicMock
state = {
"nodes": list(initial_nodes or []),
"connections": list(initial_conns or []),
"calls": [], # list of (method, url, json) tuples
"calls": [],
"next_id": 1,
}
fail = fail_endpoints or set()
@@ -1404,7 +1282,6 @@ def _make_mock_9router(initial_nodes=None, initial_conns=None, fail_endpoints=No
async def _put(url, json=None, **kw):
state["calls"].append(("PUT", url, json))
# /api/provider-nodes/<id>
for n in state["nodes"]:
if url.endswith(f"/provider-nodes/{n['id']}"):
n.update(json or {})
@@ -1424,7 +1301,6 @@ def _make_mock_9router(initial_nodes=None, initial_conns=None, fail_endpoints=No
for n in list(state["nodes"]):
if url.endswith(f"/provider-nodes/{n['id']}"):
state["nodes"].remove(n)
# Cascade-delete connections.
state["connections"] = [
c for c in state["connections"] if c.get("provider") != n["id"]
]
@@ -1461,7 +1337,6 @@ def test_sync_custom_providers_silently_noop_when_9router_down():
from backend.apps.settings.models import CustomProvider
with upatch("backend.apps.nine_router.is_running", return_value=False):
# Should not raise even with malformed/empty input.
asyncio.run(sync_custom_providers([]))
asyncio.run(sync_custom_providers([
CustomProvider(name="X", base_url="https://x/v1", api_key="k"),
@@ -1483,7 +1358,6 @@ def test_sync_custom_providers_creates_node_and_connection_for_new_provider():
api_key="key1", models=[]),
]))
# Should have POSTed exactly one node and one connection.
posts = [c for c in state["calls"] if c[0] == "POST"]
assert len(posts) == 2, f"expected 2 POSTs, got {len(posts)}: {posts}"
node_post = next(c for c in posts if "/provider-nodes" in c[1])
@@ -1530,20 +1404,18 @@ def test_sync_custom_providers_updates_existing_node_in_place():
asyncio.run(sync_custom_providers([
CustomProvider(
name="Together AI",
base_url="https://api.together.xyz/v1", # unchanged URL
api_key="new-key", # changed key
base_url="https://api.together.xyz/v1",
api_key="new-key",
models=[],
),
]))
# Should PUT the node, PATCH the connection. NO new POSTs.
posts = [c for c in state["calls"] if c[0] == "POST"]
puts = [c for c in state["calls"] if c[0] == "PUT"]
patches = [c for c in state["calls"] if c[0] == "PATCH"]
assert posts == [], f"expected no new nodes/conns, got {posts}"
assert len(puts) >= 1, f"expected node PUT, got {puts}"
assert len(patches) >= 1, f"expected conn PATCH, got {patches}"
# And the apiKey should be the new one in the patched payload.
assert patches[0][2]["apiKey"] == "new-key"
@@ -1562,10 +1434,9 @@ def test_sync_custom_providers_deletes_orphaned_managed_nodes():
"prefix": "cp-oldprovider",
"type": "openai-compatible",
},
# An UNMANAGED node — should never be deleted.
{
"id": "node-user-created",
"name": "Manual Setup", # no suffix
"name": "Manual Setup",
"prefix": "manual",
"type": "openai-compatible",
},
@@ -1574,7 +1445,7 @@ def test_sync_custom_providers_deletes_orphaned_managed_nodes():
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: _async_return([])):
asyncio.run(sync_custom_providers([])) # empty list → delete all managed
asyncio.run(sync_custom_providers([]))
deletes = [c for c in state["calls"] if c[0] == "DELETE"]
deleted_urls = [c[1] for c in deletes]
@@ -1608,7 +1479,7 @@ def test_sync_custom_providers_skips_incomplete_entries():
def test_sync_custom_providers_handles_node_post_failure_without_crashing():
"""If 9Router rejects the node POST (e.g. duplicate prefix), don't
crash the whole sync log and move on to the next provider."""
crash the whole sync; log and move on to the next provider."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
@@ -1618,7 +1489,6 @@ def test_sync_custom_providers_handles_node_post_failure_without_crashing():
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: _async_return([])):
# Should NOT raise.
asyncio.run(sync_custom_providers([
CustomProvider(name="A", base_url="https://a/v1", api_key="k1"),
CustomProvider(name="B", base_url="https://b/v1", api_key="k2"),
@@ -1643,7 +1513,6 @@ def test_sync_custom_providers_three_distinct_providers_create_three_nodes():
CustomProvider(name="Groq", base_url="https://api.groq.com/openai/v1", api_key="k3"),
]))
# Should have POSTed 3 nodes + 3 connections = 6 POSTs.
posts = [c for c in state["calls"] if c[0] == "POST"]
assert len(posts) == 6, f"expected 6 POSTs (3 nodes + 3 conns), got {len(posts)}"
@@ -1661,15 +1530,11 @@ def _async_return(value):
return _f()
# ===========================================================================
# Group S — calculate_cost regression tests
# ===========================================================================
def test_calculate_cost_anthropic_sonnet():
"""Sonnet $3/M input + $15/M output."""
from backend.apps.agents.providers.registry import calculate_cost
# 1M input, 1M output → $18 expected (3 + 15)
cost = calculate_cost("Anthropic", "sonnet", 1_000_000, 1_000_000)
assert 17 <= cost <= 19
@@ -1686,9 +1551,6 @@ def test_calculate_cost_unknown_model_returns_zero():
assert cost == 0.0
# ===========================================================================
# Group T — Mode definitions
# ===========================================================================
def test_agent_mode_no_explicit_tools():
@@ -1719,9 +1581,6 @@ def test_view_builder_mode_has_default_folder():
assert vb.default_folder is not None
# ===========================================================================
# Group U — Stress: gate handles 100 sequential calls without state leak
# ===========================================================================
@pytest.mark.asyncio
@@ -1740,9 +1599,6 @@ async def test_gate_100_sequential_calls_no_leak():
f"iteration {i}: expected {set(active)}, got {set(result.keys())}"
# ===========================================================================
# Group V — Discord shim entrypoint sanity
# ===========================================================================
def test_discord_shim_main_callable():
@@ -1753,13 +1609,9 @@ def test_discord_shim_main_callable():
def test_discord_shim_package_importable():
import backend.apps.discord_mcp_shim
# Empty __init__ now; just confirm the package imports without error
assert backend.apps.discord_mcp_shim is not None
# ===========================================================================
# Group W — Tools/web.py (live MCP for DDG search)
# ===========================================================================
def test_web_tools_classes_inherit_basetool():
@@ -1783,9 +1635,6 @@ def test_web_fetch_tool_has_name_and_schema():
assert isinstance(tool.get_schema(), dict)
# ===========================================================================
# Group X — ToolGroupMeta + caching
# ===========================================================================
def test_tool_group_meta_round_trip():
@@ -1804,9 +1653,6 @@ def test_tool_group_meta_default_is_refined_false():
assert m.is_refined is False
# ===========================================================================
# Group Y — MessageBranch invariants
# ===========================================================================
def test_session_has_main_branch_by_default():
@@ -1826,19 +1672,16 @@ def test_branch_serialization():
assert s2.branches["alt"].parent_branch_id == "main"
# ===========================================================================
# Group Z — End-to-end: realistic session lifecycle
# ===========================================================================
@pytest.mark.asyncio
async def test_e2e_session_lifecycle_with_mcp_activation():
"""
Walk a session through the realistic flow:
1. Fresh session (active_mcps empty) gate blocks all MCPs
2. MCPActivate('gmail') set fresh_session, append to active_mcps
3. Continue turn gate now passes gmail through
4. Persist & re-load state survives
1. Fresh session (active_mcps empty); gate blocks all MCPs
2. MCPActivate('gmail'); set fresh_session, append to active_mcps
3. Continue turn; gate now passes gmail through
4. Persist & re-load; state survives
"""
from backend.apps.agents.agent_manager import AgentManager
from backend.apps.agents.models import AgentSession
@@ -1848,21 +1691,18 @@ async def test_e2e_session_lifecycle_with_mcp_activation():
mgr = AgentManager()
s = AgentSession(id="e2e", name="End-to-end", model="sonnet", mode="agent")
# Step 1: fresh, gate blocks everything
result = await mgr._build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack"],
active_mcps=s.active_mcps,
)
assert result == {}
# Step 2: simulate MCPActivate
s.active_mcps.append("gmail")
s.sdk_session_id = "claude-existing"
if s.sdk_session_id:
s.needs_fresh_session = True
s.pending_continuation = True
# Step 3: continuation turn — gate passes gmail
result = await mgr._build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack"],
active_mcps=s.active_mcps,
@@ -1870,7 +1710,6 @@ async def test_e2e_session_lifecycle_with_mcp_activation():
assert "gmail" in result
assert "slack" not in result
# Step 4: persist + reload
dumped = json.dumps(s.model_dump(mode="json"))
s2 = AgentSession.model_validate(json.loads(dumped))
assert s2.active_mcps == ["gmail"]
@@ -1919,7 +1758,7 @@ def test_session_agent_active_ms_round_trip():
def test_session_agent_active_ms_accumulates_via_dict_update():
"""Simulates two turns adding to the bucket the production accumulator
"""Simulates two turns adding to the bucket; the production accumulator
pattern in agent_manager._on_result."""
from backend.apps.agents.models import AgentSession
s = AgentSession(name="t", model="sonnet", mode="agent")
@@ -1932,14 +1771,11 @@ def test_session_agent_active_ms_accumulates_via_dict_update():
def test_session_time_per_model_records_switch():
"""Simulates a model switch mid-session each model accumulates its
"""Simulates a model switch mid-session; each model accumulates its
own bucket."""
from backend.apps.agents.models import AgentSession
s = AgentSession(name="t", model="haiku", mode="agent")
# Turn 1 on haiku
s.time_per_model[s.model] = int(s.time_per_model.get(s.model, 0)) + 1200
# User switches to sonnet
s.model = "sonnet"
# Turn 2 on sonnet
s.time_per_model[s.model] = int(s.time_per_model.get(s.model, 0)) + 8400
assert s.time_per_model == {"haiku": 1200, "sonnet": 8400}
-5
View File
@@ -14,11 +14,8 @@ def debug(*args, mode:str='debug', override_max_chars:bool=False):
calling_file_name = os.path.basename(code.co_filename)
if calling_function_name == "<module>":
calling_function_name = calling_file_name
# Retrieve the file path of the calling function
file_path = os.path.abspath(code.co_filename)
# print(f"FILE PATH: {file_path}")
t_color, t_is_on, t_emoji = Debugleton().find_file_info(file_path)
# print(f"DEBUGGING: {t_color}, {t_is_on}")
max_chars = 3000
with open(code.co_filename, 'r', encoding='utf-8') as f:
@@ -44,7 +41,6 @@ def debug(*args, mode:str='debug', override_max_chars:bool=False):
arg_value = arg_value[:int(max_chars/2)] + "...\n..." + arg_value[arg_len-int(max_chars/2):]
function_print_str = calling_function_name if 'self' not in frame.f_locals else f'{frame.f_locals["self"].__class__.__name__}.{calling_function_name}'
# color = COLORS.get(function_print_str, white)
color = hex_to_rgb(t_color)
if arg_is_text:
print_str = f"{t_emoji}{rgb_to_ansi(color)}{indent_str}[{function_print_str}] : {bold_and_italicize_text(arg_value)}\033[0m"
@@ -52,6 +48,5 @@ def debug(*args, mode:str='debug', override_max_chars:bool=False):
print_str = f"{t_emoji}{rgb_to_ansi(color)}{indent_str}[{function_print_str}] : {arg_name} = {arg_value}\033[0m"
if t_is_on: log_config.debug_custom(print_str, mode)
# Assign the function to the module's __call__ attribute
import sys
sys.modules[__name__] = debug
+2 -6
View File
@@ -13,9 +13,7 @@ class DebugFile(File):
self.directory = directory # Reference to parent directory
def to_dict(self):
"""
Converts the DebugFile object to a dictionary format.
"""
"""Convert the DebugFile to a dict."""
return {
"name": os.path.basename(self.filename),
"color": self.color,
@@ -26,9 +24,7 @@ class DebugFile(File):
@classmethod
def from_dict(cls, file_dict, directory):
"""
Creates a DebugFile object from a dictionary loaded from JSON.
"""
"""Build a DebugFile from a JSON-loaded dict."""
filename = os.path.join(directory.path, file_dict["name"])
return cls(
filename=filename,
+1 -14
View File
@@ -16,7 +16,6 @@ class Debugleton:
sync_lock: threading.Lock
def __new__(cls, *args, **kwargs):
# Double-checked locking for thread-safe singleton creation
if cls._instance is None:
with cls._lock:
if cls._instance is None:
@@ -32,27 +31,18 @@ class Debugleton:
print("\033[38;5;120m|\t...Project Scanned\t|\033[0m")
print("\033[38;5;120m|\tDEBUGLETON INIT DONE\t|\033[0m")
print("\033[38;5;120m---------------------------------\n\033[0m")
# else: print("DEBUGLETON Already initialized INNER")
# else: print("DEBUGLETON Already initialized OUTER")
return cls._instance
def sync_to_saved(self, is_first_sync=False):
# print(f"[sync_to_saved]: START")
if not is_first_sync: self.sync_lock.acquire()
# print(f"[sync_to_saved]: Acquired sync lock")
self.dir = update_debug_toggles(save_to_file=False)
# print(f"Synced to saved dir: {self.dir}")
self.abspaths, self.instances = self.dir.get_ordered_abspaths_and_instances()
# print(f"Synced to abspaths: {self.abspaths}")
with open(NEEDS_RESYNC_FILE, 'w') as f:
f.write('0')
if not is_first_sync: self.sync_lock.release()
# print(f"[sync_to_saved]: Released sync lock")
# print(f"[sync_to_saved]: END")
def needs_resync(self):
# print(f"[needs_resync]: START")
num_tries = 0
while self.is_syncing():
print(f"Waiting for Debugleton to sync... ({num_tries})")
@@ -67,8 +57,6 @@ class Debugleton:
""")
with open(NEEDS_RESYNC_FILE, 'r') as f:
does_need_resync = True if f.read().strip() == '1' else False
# if does_need_resync: print("Resyncing Debugleton...")
# print(f"[needs_resync]: END")
return does_need_resync
def is_syncing(self):
@@ -76,7 +64,6 @@ class Debugleton:
def find_file_info(self, filepath: str):
filepath = filepath.lower()
# print(f"Finding file info for {filepath}")
if self.needs_resync():
self.sync_to_saved()
try:
+12 -52
View File
@@ -7,11 +7,10 @@ from debugger_backend.DEFAULTS import DEFAULT_COLOR, DEFAULT_TOGGLED, DEFAULT_SE
from debugger_backend.path_mngr import get_abspath, get_root_rel_path
class Directory:
def __init__(self, path, color=DEFAULT_COLOR, is_toggled=DEFAULT_TOGGLED,
def __init__(self, path, color=DEFAULT_COLOR, is_toggled=DEFAULT_TOGGLED,
set_manually=DEFAULT_SET_MANUALLY, emoji=DEFAULT_EMOJI):
self.path = path
# print(f"Directory init: {self.path}")
self.children = [] # Can contain DebugFile or other Directory objects
self.children = []
self.color = color
self.is_toggled = is_toggled
self.set_manually = set_manually
@@ -24,39 +23,26 @@ class Directory:
return get_abspath(self.path)
def add_child(self, child):
"""
Adds a child to the directory (either a DebugFile or another Directory).
"""
"""Append a child DebugFile/Directory."""
self.children.append(child)
def get_ordered_abspaths_and_instances(self):
# print("[get_ordered_abspaths]: START")
curr_file_path = os.path.abspath(__file__)
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(curr_file_path)))
# print(f"[get_ordered_abspaths]: Curr path: {curr_file_path}")
# print(f"[get_ordered_abspaths]: Dir path: {root_dir}")
def construct_ordered_abspaths(dir: Directory, ordered_abspaths: list):
dir_path = dir.path
full_path = os.path.join(root_dir, dir_path)
ordered_abspaths.append({"abspath": full_path, "instance": dir})
# print(f"\t[construct_ordered_abspaths]: Full path: {full_path}")
for child in dir.children:
child_abspath = os.path.join(root_dir, child.path).lower()
if os.path.isdir(child_abspath):
construct_ordered_abspaths(child, ordered_abspaths)
elif os.path.isfile(child_abspath):
# print(f"\t[construct_ordered_abspaths]: Child is file: {child_abspath}")
ordered_abspaths.append({"abspath": child_abspath, "instance": child})
else:
print(f"\033[38;5;120mEntry is non existent: {child_abspath}\033[0m")
# print(f"\t[construct_ordered_abspaths]: Finished for dir: {full_path}")
# print(f"\t[construct_ordered_abspaths]: RETURNING FROM DIR: {full_path}")
return ordered_abspaths
ordered_abspaths_and_instances = construct_ordered_abspaths(self, [])
# print("[get_ordered_abspaths]: Finished getting ordered abspaths and instances")
# for abspath_and_instance in ordered_abspaths_and_instances:
# abspath = abspath_and_instance["abspath"]
# print(f"\t[get_ordered_abspaths]: Abspath: {abspath}")
ordered_abspaths = [abspath_and_instance["abspath"] for abspath_and_instance in ordered_abspaths_and_instances]
ordered_instances = [abspath_and_instance["instance"] for abspath_and_instance in ordered_abspaths_and_instances]
return ordered_abspaths, ordered_instances
@@ -65,17 +51,12 @@ class Directory:
def build_structure(self):
print("[build_structure]: START")
root_dir = self.get_abspath()
# print(f"[build_structure]: Root dir: {root_dir}")
excluded_dirs = [".venv", "debugger", "node_modules", ".git", "__pycache__"]
project_structure = []
def construct_project_structure(dir_path: str, parent_dir: Directory):
# print(f"[build_structure]: Scanning dir: {dir_path}")
with os.scandir(dir_path) as it:
for entry in it:
# print(f"[build_structure]: Entry: {entry.path}")
if any(excluded_dir in entry.path for excluded_dir in excluded_dirs):
# print(f"[build_structure]: Excluding {entry.path}")
continue
root_rel_path = get_root_rel_path(entry.path)
if entry.is_dir():
@@ -88,16 +69,12 @@ class Directory:
parent_dir.add_child(debug_file)
else:
continue
construct_project_structure(root_dir, self)
# [print(f"[build_structure]: {file}") for file in project_structure]
# print(f"[build_structure]: END")
construct_project_structure(root_dir, self)
return
def to_dict(self):
"""
Converts the Directory object to a dictionary format, recursively.
"""
"""Recursively convert the Directory to a dict."""
return {
"name": os.path.basename(self.path),
"color": self.color,
@@ -108,22 +85,14 @@ class Directory:
}
def prune_empty(self):
# Recursively prune empty directories
# Base case) if the current directory has no children, return
# Recursive case) for each of the directories in the current directory, call prune_empty
# then remove the directory from the children of the current directory if it has no children
for child in self.children[:]:
if isinstance(child, Directory):
# Recursively prune empty subdirectories
child.prune_empty()
# If the subdirectory is empty after pruning, remove it
if len(child.children) == 0:
self.children.remove(child)
def propagate_toggled_state(self):
"""
Propagates the toggled state down the hierarchy.
"""
"""Propagate the toggled state down the hierarchy."""
for child in self.children:
if isinstance(child, DebugFile) and not child.set_manually:
child.is_toggled = self.is_toggled
@@ -132,9 +101,7 @@ class Directory:
child.propagate_toggled_state()
def propagate_color(self, parent_color=DEFAULT_COLOR):
"""
Propagates the color from parent to children.
"""
"""Propagate color from parent to children."""
if self.color == DEFAULT_COLOR:
self.color = lighten_color(parent_color)
for child in self.children:
@@ -144,9 +111,7 @@ class Directory:
child.propagate_color(self.color)
def load_from_json(self, json_data):
"""
Loads a directory structure from a JSON file into this Directory instance.
"""
"""Load a directory structure from JSON into this Directory."""
for item in json_data:
if 'children' in item:
subdir = Directory(
@@ -160,7 +125,6 @@ class Directory:
subdir.load_from_json(item['children'])
self.add_child(subdir)
else:
# debug_file = DebugFile.from_dict(item, self)
debug_file = DebugFile(
filename=item['name'],
path=os.path.join(self.path, item['name']),
@@ -173,9 +137,7 @@ class Directory:
self.add_child(debug_file)
def reset_colors(self):
"""
Resets the color of all DebugFile and Directory objects in this directory structure to the default color.
"""
"""Reset every nested color to the default."""
self.color = DEFAULT_COLOR
for child in self.children:
if isinstance(child, DebugFile):
@@ -185,9 +147,7 @@ class Directory:
def lighten_color(color, amount=0.1):
"""
Lightens the given color by the specified amount.
"""
"""Lighten the given color by amount."""
try:
color = color.lstrip('#')
r, g, b = int(color[:2], 16), int(color[2:4], 16), int(color[4:6], 16)
+1 -4
View File
@@ -10,9 +10,7 @@ class File:
return get_abspath(self.path)
def calls_debug_function(self):
"""
Checks if the file calls the debug function.
"""
"""True if the file contains a debug() call."""
full_path = self.get_abspath()
if not full_path.endswith('.py') or full_path.endswith('.pyc'):
@@ -25,5 +23,4 @@ class File:
except (UnicodeDecodeError, FileNotFoundError) as e:
print(f"Error reading file {full_path}")
result = False
# print(f"??calls_debug_function?? {result}")
return result
+3 -6
View File
@@ -1,9 +1,9 @@
import colorsys
def adjust_brightness(color, brightness_factor):
hls = colorsys.rgb_to_hls(*[x/255.0 for x in color]) # Convert RGB to HLS
hls = (hls[0], max(0, min(1, hls[1] + brightness_factor)), hls[2]) # Adjust lightness
rgb = [int(x*255.0) for x in colorsys.hls_to_rgb(*hls)] # Convert back to RGB
hls = colorsys.rgb_to_hls(*[x/255.0 for x in color])
hls = (hls[0], max(0, min(1, hls[1] + brightness_factor)), hls[2])
rgb = [int(x*255.0) for x in colorsys.hls_to_rgb(*hls)]
return rgb
@@ -14,8 +14,5 @@ def bold_and_italicize_text(text):
return f"\033[1m\033[3m{text}\033[0m"
def hex_to_rgb(hex_code):
# Remove the '#' symbol if it exists
hex_code = hex_code.lstrip('#')
# Convert the hex code to RGB
return tuple(int(hex_code[i:i+2], 16) for i in (0, 2, 4))
@@ -2,7 +2,6 @@
def is_fstring(arg_name):
if not isinstance(arg_name, str):
return False
# print(f"arg_name: {arg_name}")
fstring_start_values = ["f'", "f\""]
num_start_matches = sum(arg_name.startswith(start_value) for start_value in fstring_start_values)
conditions = [num_start_matches == 1]
@@ -12,7 +11,6 @@ def is_text(arg_value, arg_name):
arg_is_text = isinstance(arg_value, str) and len(arg_name) > 2 and arg_name[1:len(arg_name)-1] == arg_value and not arg_name.endswith(")")
if not arg_is_text:
arg_is_text = is_fstring(arg_name)
# print(f"is_text: {arg_is_text}")
return arg_is_text
def is_error(arg_value, arg_name):
@@ -12,10 +12,8 @@ CORS(app)
def api_get_structure():
print("GET /get_structure")
scanned_dir=update_debug_toggles(save_to_file=True)
# print("\n\nPS scanned_dir: ", scanned_dir)
output = dir_to_output_format(scanned_dir)
output = json.dumps(output, ensure_ascii=False, indent=4)
# print("output: ", output)
return Response(output, mimetype='application/json')
@app.route('/push_structure', methods=['POST'])
@@ -23,7 +21,6 @@ def api_push_structure():
print("POST /push_structure")
data = request.get_json()
data = data['projectStructure']
# print(data)
with open(DEBUG_TOGGLE_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, indent=4)
with open(NEEDS_RESYNC_FILE, 'w') as f:
@@ -35,10 +32,8 @@ def api_reset_color():
print("POST /reset_color")
scanned_dir=update_debug_toggles(save_to_file=False)
scanned_dir.reset_colors()
# print("RS: scanned_dir: ", scanned_dir)
output = dir_to_output_format(scanned_dir)
output = json.dumps(output, ensure_ascii=False, indent=4)
# print("RS: output: ", output)
return Response(output, mimetype='application/json')
+1 -3
View File
@@ -19,12 +19,11 @@ class LogConfig:
for name, level in self.MODES.items():
logging.addLevelName(level, name.upper())
self.logger = logging.getLogger('custom_logger')
self.logger.propagate = False # Prevent log propagation
self.logger.propagate = False
handler = logging.StreamHandler()
formatter = logging.Formatter('%(message)s')
handler.setFormatter(formatter)
# Remove existing handlers to prevent duplicate logging
if self.logger.hasHandlers():
self.logger.handlers.clear()
@@ -39,7 +38,6 @@ class LogConfig:
def set_debug_mode(self, mode):
current_mode = get_log_mode()
# print(f"Setting debug mode from {current_mode} -> to {mode}")
if mode not in self.MODES: raise ValueError(f"Invalid mode: {mode}")
set_log_mode(mode)
self.logger.setLevel(self.MODES[mode])
+1 -2
View File
@@ -1,6 +1,5 @@
import os
# LOG_MODE_FILE = 'debugger/log_mode.txt'
LOG_MODE_FILE = os.path.join(os.path.dirname(__file__), 'log_mode.txt')
def set_log_mode(mode):
with open(LOG_MODE_FILE, 'w') as f:
@@ -10,4 +9,4 @@ def get_log_mode():
if os.path.exists(LOG_MODE_FILE):
with open(LOG_MODE_FILE, 'r') as f:
return f.read().strip()
return 'all' # Default to 'all' if the file doesn't exist
return 'all'
+11 -50
View File
@@ -8,16 +8,9 @@ from debugger_backend.DebugFile import DebugFile
from collections import OrderedDict
def merge_directories(json_dir: Directory, scanned_dir: Directory):
"""
Merges two Directory instances: one loaded from JSON (json_dir) and one built from scanning (scanned_dir).
The values from json_dir take precedence where attributes overlap.
It matches based on full directory and file structure, not just file names.
"""
# print(f"Merging JSON_DIR: {json_dir.path}\n with SCAN_DIR: {scanned_dir.path}")
"""Merge json_dir into scanned_dir; json values win on overlap, matched by full path."""
json_abspaths, json_instances = json_dir.get_ordered_abspaths_and_instances()
# print(f"json_abspaths: {json_abspaths}")
scanned_abspaths, scanned_instances = scanned_dir.get_ordered_abspaths_and_instances()
# print(f"scanned_abspaths: {scanned_abspaths}")
def find_matching_in_structure(scanned_child: Union[DebugFile, Directory], json_dir: Directory):
assert json_dir in json_instances, f"JSON_DIR: {json_dir.path} not in json_instances"
@@ -28,32 +21,26 @@ def merge_directories(json_dir: Directory, scanned_dir: Directory):
try:
json_id = json_abspaths.index(scanned_abspath)
json_instance = json_instances[json_id]
# print(f"Match found: {scanned_child.path} == {json_instance.path}")
except ValueError:
# print(f"SCANNED_ABSPATH: {scanned_abspath} not in JSON_ABSPATHS")
pass
return json_instance
def construct_merged_dir(json_dir: Directory, scanned_dir: Directory):
for scanned_child in scanned_dir.children:
# Use the new recursive function to find the corresponding child in the JSON directory structure
matching_json_child = find_matching_in_structure(scanned_child, json_dir)
if isinstance(scanned_child, DebugFile) and matching_json_child:
# Merge attributes from the JSON-loaded structure
scanned_child.color = matching_json_child.color
scanned_child.is_toggled = matching_json_child.is_toggled
scanned_child.set_manually = matching_json_child.set_manually
scanned_child.emoji = matching_json_child.emoji
elif isinstance(scanned_child, Directory) and matching_json_child:
# Merge directory attributes
scanned_child.color = matching_json_child.color
scanned_child.is_toggled = matching_json_child.is_toggled
scanned_child.set_manually = matching_json_child.set_manually
scanned_child.emoji = matching_json_child.emoji
# Recursively merge the subdirectories
construct_merged_dir(matching_json_child, scanned_child)
else:
scanned_child.color = DEFAULT_COLOR
@@ -65,7 +52,6 @@ def merge_directories(json_dir: Directory, scanned_dir: Directory):
def update_debug_toggles(save_to_file=True) -> Directory:
# print(f"[update_debug_toggles]: START")
json_loaded_dir = None
if os.path.exists(TOGGLE_FILE):
with open(TOGGLE_FILE, 'r', encoding='utf-8') as file:
@@ -78,65 +64,40 @@ def update_debug_toggles(save_to_file=True) -> Directory:
set_manually=json_data[0].get('set_manually', DEFAULT_SET_MANUALLY),
emoji=json_data[0].get('emoji', DEFAULT_EMOJI)
)
# print(f"Root: {json_loaded_dir}")
# print("Json Children 1:")
# [print(child.path) for child in json_loaded_dir.children]
json_loaded_dir.load_from_json(json_data[0]['children']) # Assuming the root is in json_data[0]
# print("Json Children 2:")
# [print(child.path) for child in json_loaded_dir.children]
json_loaded_dir.load_from_json(json_data[0]['children'])
except json.JSONDecodeError:
ValueError("Error: JSON file could not be decoded.")
else:
print("No JSON file found")
# 1. Create a directory structure from the filesystem scan
# print("Scanning directory...")
scanned_dir = Directory(path="",
color=json_loaded_dir.color if json_loaded_dir else DEFAULT_COLOR,
is_toggled=json_loaded_dir.is_toggled if json_loaded_dir else DEFAULT_TOGGLED,
scanned_dir = Directory(path="",
color=json_loaded_dir.color if json_loaded_dir else DEFAULT_COLOR,
is_toggled=json_loaded_dir.is_toggled if json_loaded_dir else DEFAULT_TOGGLED,
set_manually=json_loaded_dir.set_manually if json_loaded_dir else DEFAULT_SET_MANUALLY,
emoji=json_loaded_dir.emoji if json_loaded_dir else DEFAULT_EMOJI
)
# print(f"\n\nNum Children 1: {len(scanned_dir.children)}")
# [print(child.path) for child in scanned_dir.children]
scanned_dir.build_structure()
# print(f"\n\nNum Children 2: {len(scanned_dir.children)}")
# [print(child.path) for child in scanned_dir.children]
scanned_dir.prune_empty()
# print(f"\n\nNum Children 3: {len(scanned_dir.children)}")
# [print(child.path) for child in scanned_dir.children]
# print("1.1 Merged Dir First Child: ", scanned_dir.children[0])
# 4. Propagate the toggled state and color through the merged structure
scanned_dir.propagate_toggled_state()
# print(f"\n\nNum Children 4: {len(scanned_dir.children)}")
# [print(child.path) for child in scanned_dir.children]
# 3. Merge the two directory structures
if json_loaded_dir:
merge_directories(json_loaded_dir, scanned_dir)
# print(f"\n\nNum Children 5: {len(scanned_dir.children)}")
scanned_dir.propagate_color()
output = dir_to_output_format(scanned_dir)
# print(f"\n\nNum Children 6: {len(scanned_dir.children)}")
# 5. Write the updated structure back to the JSON file
if save_to_file:
with open(TOGGLE_FILE, 'w', encoding='utf-8') as file:
json.dump(output, file, ensure_ascii=False, indent=4)
# print(f"[update_debug_toggles]: END")
return scanned_dir
def dir_to_output_format(input_dir):
root_node = {
"name": "root",
"color": input_dir.color, # Use input_dir's color
"is_toggled": input_dir.is_toggled, # Use input_dir's toggled state
"set_manually": input_dir.set_manually, # Use input_dir's set_manually
"emoji": input_dir.emoji, # Use input_dir's emoji
"color": input_dir.color,
"is_toggled": input_dir.is_toggled,
"set_manually": input_dir.set_manually,
"emoji": input_dir.emoji,
"children": input_dir.to_dict()["children"]
}
return [ordered(root_node)]
+1 -4
View File
@@ -1,9 +1,6 @@
from setuptools import setup, find_packages
# `py_modules` exposes BOTH `debug` (legacy import name used by OpenSwarm's
# own backend) and `swarm_debug` (the import name the webapp-template
# scaffold uses, matching the published-package convention `swarm-debug`).
# The `swarm_debug` module is a thin re-export of `debug` — see swarm_debug.py.
# Exposes both `debug` (legacy) and `swarm_debug` (webapp-template convention; thin re-export).
setup(
name="debug",
version="0.1",
+2 -16
View File
@@ -1,21 +1,7 @@
"""Module alias — exposes the `debug()` function under the `swarm_debug`
name so code that does `from swarm_debug import debug` resolves to the
same OpenSwarm-bundled package that the legacy `import debug` path
already serves.
"""Re-exports debug() under swarm_debug; debug.py swaps sys.modules to the function so from-imports there don't work."""
`debug.py` ends with `sys.modules[__name__] = debug`, which replaces the
module object with the bare function. That trick lets OpenSwarm's own
code write `import debug; debug(x)` (the imported name binds to the
function directly), but it means `from debug import debug` doesn't work
(you can't attribute-walk a function). This shim captures the function
via `import debug` (which now binds to the function thanks to the
sys.modules swap) and re-exports it as a normal module attribute, so
the more conventional `from swarm_debug import debug` pattern works.
"""
import debug as _debug # noqa: F401
import debug as _debug # noqa: F401 — `_debug` is actually the function
# Re-export as a module attribute so `from swarm_debug import debug` resolves.
debug = _debug
__all__ = ["debug"]
+5 -50
View File
@@ -1,23 +1,4 @@
// Affiliate / referral install tracking on the desktop side.
//
// On first launch the app opens https://openswarm.com/welcome?app_install_id=…
// in the user's default browser and polls the cloud's /api/install/lookup
// endpoint until a referral binding shows up (or we time out). The browser
// page is what actually performs the bind: it reads the install_token that
// the landing page stashed in localStorage / cookie when the user clicked
// Download, and POSTs it to the cloud paired with our app_install_id.
//
// State lives in `<userData>/install.json`. The shape:
// {
// app_install_id: "uuid", // generated once per install
// first_launch_at: 1700000000000, // unix ms; presence = "this isn't first launch"
// ref: "haik" | null, // populated once lookup succeeds
// ref_bound_at: 1700000000000 | null,
// attempts: 0 // last polling attempt count, for debugging
// }
//
// Skipped entirely in dev unless OPENSWARM_AFFILIATE_FORCE=1 is set, so
// `bash run.sh` doesn't pop a browser tab on every restart.
// First-launch affiliate ref capture: opens welcome page, polls cloud lookup, persists to install.json.
const fs = require("fs");
const path = require("path");
@@ -26,13 +7,7 @@ const crypto = require("crypto");
const DEFAULT_LANDING_URL = "https://openswarm.com";
const DEFAULT_CLOUD_URL = "https://api.openswarm.com";
// Polling: 12 attempts, 5s apart = 60s window. Generous enough for the user
// to actually click through the welcome page; small enough that a stuck
// poll doesn't sit around all day. The page itself is fast (single POST)
// so most binds land in the first one or two ticks.
//
// Both knobs are overridable via env so tests can drive a 200ms × 5
// poll window instead of 60s.
// 12 attempts * 5s = 60s window; env-overridable for tests.
const POLL_INTERVAL_MS = Number(process.env.OPENSWARM_AFFILIATE_POLL_INTERVAL_MS) || 5000;
const POLL_MAX_ATTEMPTS = Number(process.env.OPENSWARM_AFFILIATE_POLL_MAX_ATTEMPTS) || 12;
@@ -54,9 +29,7 @@ function writeState(userDataDir, state) {
const p = getStateFilePath(userDataDir);
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
// Atomic-ish write: temp file + rename. Avoids leaving a half-written
// install.json if the process is killed mid-write (which would brick
// first-launch detection on the next start).
// Atomic write so kill mid-write doesn't brick first-launch detection.
const tmp = p + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
fs.renameSync(tmp, p);
@@ -74,8 +47,6 @@ function urlsFromEnv() {
async function pollLookupOnce(cloudUrl, appInstallId) {
const url = `${cloudUrl}/api/install/lookup?app_install_id=${encodeURIComponent(appInstallId)}`;
// Node 18+ ships global fetch; Electron 40 is on a Chromium that has it.
// Defensive timeout via AbortSignal.timeout (Node 17+).
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
try {
@@ -114,32 +85,20 @@ async function pollUntilBound({ cloudUrl, appInstallId, userDataDir }) {
return null;
}
// Public entry: call once from app.whenReady() after backend is up. Safe to
// call on every launch — internal first-launch check makes subsequent calls
// a no-op. `shell` is electron's shell module, passed in to avoid this
// module needing to require electron at the top (keeps it test-friendly).
/** Run once from app.whenReady(); idempotent across launches. */
async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPackaged }) {
// Skip in dev to avoid spawning a browser tab on every `bash run.sh`.
// OPENSWARM_AFFILIATE_FORCE=1 lets us actually exercise the flow against
// a local landing page + local cloud during integration testing.
if (isDev && process.env.OPENSWARM_AFFILIATE_FORCE !== "1") {
return;
}
const state = readState(userDataDir);
if (state.first_launch_at) {
// Returning launch. If we never managed to bind a ref, optionally try
// again — but only for a short grace window after the original launch
// (24h) so we don't pop a browser tab on someone who's been using the
// app for a month.
// Re-poll on returning launches only within a 24h grace; don't spam old installs.
const ageMs = Date.now() - Number(state.first_launch_at || 0);
const stillInGracePeriod = Number.isFinite(ageMs) && ageMs >= 0 && ageMs < 24 * 60 * 60 * 1000;
if (state.ref || !stillInGracePeriod || !state.app_install_id) {
return;
}
// Within grace window and still no ref — silently re-poll (no second
// browser pop-up) in case the user hasn't completed the welcome page
// handshake yet.
pollUntilBound({
cloudUrl: urlsFromEnv().cloudUrl,
appInstallId: state.app_install_id,
@@ -148,7 +107,6 @@ async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPacka
return;
}
// First launch.
const appInstallId = crypto.randomUUID();
const now = Date.now();
const fresh = {
@@ -172,8 +130,6 @@ async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPacka
console.warn("[affiliate] failed to open welcome URL:", err && err.message);
}
// Fire-and-forget the polling loop. We intentionally don't await it from
// app.whenReady() so backend / window startup stays unblocked.
pollUntilBound({ cloudUrl, appInstallId, userDataDir }).catch((err) => {
console.warn("[affiliate] poll loop crashed:", err && err.message);
});
@@ -181,7 +137,6 @@ async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPacka
module.exports = {
maybeRunFirstLaunchHandshake,
// Exported for tests + IPC handlers.
_readState: readState,
_writeState: writeState,
_getStateFilePath: getStateFilePath,
+4 -54
View File
@@ -1,22 +1,4 @@
// End-to-end tests for the desktop-side affiliate / referral handshake.
//
// We stand up an in-process HTTP server that implements the same contract
// as openswarm-cloud's /api/install/{mint,bind,lookup} endpoints (in-memory
// state, no SQLite). The Electron module's polling code talks to this
// server over real fetch over real loopback TCP, which is as realistic as
// it gets without booting the actual cloud Hono app.
//
// We then drive both halves of the flow:
// * The "user clicks Download on the landing page" half: mint() to get an
// install_token, stash it where the test's "welcome page" simulator can
// find it.
// * The "user installs the app" half: maybeRunFirstLaunchHandshake() with
// a fake shell that captures the welcome URL, then we simulate the
// welcome page by calling /api/install/bind from the test before the
// poll loop times out.
//
// Polling cadence is squeezed via env vars (OPENSWARM_AFFILIATE_POLL_*) so
// the suite finishes in milliseconds instead of seconds.
// E2E tests for the desktop affiliate handshake against an in-process mock cloud.
const test = require("node:test");
const assert = require("node:assert/strict");
@@ -26,17 +8,13 @@ const os = require("node:os");
const http = require("node:http");
const crypto = require("node:crypto");
// Force the tracking module to use tight polling well before requiring it,
// because the constants are read at module-load time.
// Polling envs must be set before require: constants read at module load.
process.env.OPENSWARM_AFFILIATE_POLL_INTERVAL_MS = "20";
process.env.OPENSWARM_AFFILIATE_POLL_MAX_ATTEMPTS = "30";
const affiliateTracking = require("./affiliateTracking");
// --- in-memory mock cloud --------------------------------------------------
function makeMockCloud() {
// Mirrors the install_tokens table.
const tokens = new Map();
const server = http.createServer((req, res) => {
@@ -120,8 +98,6 @@ function makeMockCloud() {
});
}
// --- fake shell ------------------------------------------------------------
function makeFakeShell() {
const opened = [];
return {
@@ -133,8 +109,6 @@ function makeFakeShell() {
};
}
// --- temp-dir helper -------------------------------------------------------
function makeTempUserDataDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openswarm-affiliate-test-"));
return dir;
@@ -148,7 +122,6 @@ function delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// Prefer the actual install_token = call the bind endpoint with it.
async function simulateWelcomePageBind(cloudUrl, installToken, appInstallId) {
const res = await fetch(`${cloudUrl}/api/install/bind`, {
method: "POST",
@@ -173,10 +146,6 @@ function appInstallIdFromWelcomeUrl(url) {
return u.searchParams.get("app_install_id");
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test("first launch: opens welcome URL and binds ref via poll loop", async () => {
const cloud = await makeMockCloud();
try {
@@ -186,11 +155,8 @@ test("first launch: opens welcome URL and binds ref via poll loop", async () =>
process.env.OPENSWARM_AFFILIATE_LANDING_URL = "https://landing.test";
process.env.OPENSWARM_AFFILIATE_CLOUD_URL = cloud.url;
// 1. Pre-mint a token at the cloud as if the user had clicked Download
// on the landing page.
const installToken = await mintTokenFromCloud(cloud.url, "haik-test");
// 2. Run the desktop's first-launch handshake.
await affiliateTracking.maybeRunFirstLaunchHandshake({
shell,
userDataDir,
@@ -198,8 +164,6 @@ test("first launch: opens welcome URL and binds ref via poll loop", async () =>
isPackaged: true,
});
// 3. The shell should have been told to open the welcome URL with the
// freshly generated app_install_id.
assert.equal(shell.opened.length, 1, "exactly one browser open");
assert.ok(
shell.opened[0].startsWith("https://landing.test/welcome?app_install_id="),
@@ -209,20 +173,17 @@ test("first launch: opens welcome URL and binds ref via poll loop", async () =>
const appInstallId = appInstallIdFromWelcomeUrl(shell.opened[0]);
assert.ok(appInstallId && appInstallId.length > 8, "app_install_id present in URL");
// 4. install.json on disk now has the app_install_id but no ref yet.
const stateFile = path.join(userDataDir, "install.json");
let state = readJson(stateFile);
assert.equal(state.app_install_id, appInstallId);
assert.equal(state.ref, null);
assert.ok(state.first_launch_at > 0);
// 5. Simulate the welcome page completing the bind.
const bindResult = await simulateWelcomePageBind(cloud.url, installToken, appInstallId);
assert.equal(bindResult.status, 200);
assert.equal(bindResult.body.ref, "haik-test");
// 6. Wait for the poll loop to pick up the bind. Poll cadence is
// 20ms × 30 attempts = ~600ms upper bound; we wait up to 1s.
// Poll budget: 20ms * 30 attempts ~= 600ms; wait up to 1s.
let final = null;
for (let i = 0; i < 50; i++) {
await delay(50);
@@ -243,8 +204,6 @@ test("returning launch: no-op when ref already bound", async () => {
const shell = makeFakeShell();
process.env.OPENSWARM_AFFILIATE_CLOUD_URL = cloud.url;
// Seed install.json as if first launch already happened and a ref
// was bound a few minutes ago.
fs.writeFileSync(
path.join(userDataDir, "install.json"),
JSON.stringify({
@@ -278,8 +237,6 @@ test("returning launch within grace window: silent re-poll, no second browser po
const shell = makeFakeShell();
process.env.OPENSWARM_AFFILIATE_CLOUD_URL = cloud.url;
// Pre-mint a token + seed install.json as if first launch happened
// but the user never completed the welcome page handshake yet.
const appInstallId = "grace-app-install-id-abcdef0123";
fs.writeFileSync(
path.join(userDataDir, "install.json"),
@@ -293,7 +250,6 @@ test("returning launch within grace window: silent re-poll, no second browser po
);
const installToken = await mintTokenFromCloud(cloud.url, "grace-ref");
// Simulate a late welcome bind (user finally clicked through).
await simulateWelcomePageBind(cloud.url, installToken, appInstallId);
await affiliateTracking.maybeRunFirstLaunchHandshake({
@@ -303,10 +259,8 @@ test("returning launch within grace window: silent re-poll, no second browser po
isPackaged: true,
});
// Specifically NO browser pop-up the second time around.
assert.equal(shell.opened.length, 0, "no second browser open");
// Wait for the silent re-poll to pick up the bind.
const stateFile = path.join(userDataDir, "install.json");
let state = null;
for (let i = 0; i < 50; i++) {
@@ -346,7 +300,6 @@ test("returning launch outside grace window: skipped entirely", async () => {
});
assert.equal(shell.opened.length, 0, "no browser open after grace window");
// Give the poll loop time to NOT run.
await delay(200);
const state = readJson(path.join(userDataDir, "install.json"));
assert.equal(state.ref, null, "no ref bound");
@@ -392,7 +345,6 @@ test("dev mode: skipped unless OPENSWARM_AFFILIATE_FORCE=1", async () => {
test("install.json write is atomic-ish (temp + rename)", async () => {
const userDataDir = makeTempUserDataDir();
affiliateTracking._writeState(userDataDir, { app_install_id: "atomic-test-1234567890", ref: "x" });
// After write, the temp file shouldn't be left behind.
const files = fs.readdirSync(userDataDir);
assert.ok(files.includes("install.json"));
assert.ok(!files.some((f) => f.endsWith(".tmp")), "no leftover temp file");
@@ -412,10 +364,8 @@ test("readState returns {} when install.json is corrupt", () => {
});
test("poll loop respects max attempts and gives up", async () => {
// No cloud server at all — every poll attempt fails (ECONNREFUSED).
const userDataDir = makeTempUserDataDir();
const shell = makeFakeShell();
// Point at a port nothing's listening on.
process.env.OPENSWARM_AFFILIATE_CLOUD_URL = "http://127.0.0.1:1";
await affiliateTracking.maybeRunFirstLaunchHandshake({
@@ -425,7 +375,7 @@ test("poll loop respects max attempts and gives up", async () => {
isPackaged: true,
});
// Wait long enough for all attempts to fail. 20ms × 30 = 600ms.
// 20ms * 30 = 600ms upper bound; wait 900ms.
await delay(900);
const state = readJson(path.join(userDataDir, "install.json"));
assert.equal(state.ref, null, "no ref after exhausted polls");
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+88 -374
View File
@@ -11,15 +11,8 @@ const affiliateTracking = require('./affiliateTracking');
const tray = require('./tray');
const workflowsLifecycle = require('./workflowsLifecycle');
// Prevent duplicate instances. Without this, double-clicking the app icon
// (or macOS auto-launch + manual launch overlapping) spawns two independent
// processes — each with its own backend on a different port — resulting in
// one populated window and one empty window.
// Register openswarm:// protocol handler BEFORE any gotLock branching.
// Must happen synchronously at the top of main.js so the OS knows this
// binary is the default handler even before whenReady fires.
// openswarm:// protocol must register synchronously at top of main.js, before gotLock branching.
if (process.defaultApp) {
// Dev run: `electron .` needs the entry-script path to re-launch cleanly.
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('openswarm', process.execPath, [path.resolve(process.argv[1])]);
}
@@ -27,30 +20,21 @@ if (process.defaultApp) {
app.setAsDefaultProtocolClient('openswarm');
}
// Pending deep-link captured before mainWindow exists (cold-launch case).
// Flushed to renderer once mainWindow is ready.
let pendingDeepLink = null;
function forwardDeepLinkToRenderer(url) {
if (!url) return;
// openswarm:// URLs split by host: "auth" subscription token,
// "oauth/{provider}/complete" → OAuth claim. Each goes to its own
// IPC channel so the renderer can route without parsing twice.
// openswarm:// splits by host: "auth" = subscription token, "oauth/{p}/complete" = OAuth claim.
let channel = 'openswarm:auth-url';
try {
const u = new URL(url);
if (u.host === 'oauth' && u.pathname.endsWith('/complete')) {
channel = 'openswarm:oauth-claim';
}
} catch (_) {
// Malformed URL — fall back to legacy channel; renderer ignores anything
// it doesn't recognise.
}
} catch (_) {}
if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isLoading()) {
mainWindow.webContents.send(channel, url);
} else {
// Stash both URL and target channel so we can flush correctly when
// the renderer is ready. Replaces the simple string with a {channel,url}.
pendingDeepLink = { channel, url };
}
}
@@ -64,9 +48,7 @@ if (!gotLock) {
app.exit(0);
} else {
app.on('second-instance', (_event, argv) => {
// Windows/Linux: a `openswarm://...` click lands here because the OS
// re-launches the app with the URL as an argv. We swallow the second
// instance, focus the existing window, and forward the URL to renderer.
// Windows/Linux: openswarm:// click re-launches the app with the URL as argv.
const url = extractOpenswarmUrl(argv);
if (url) forwardDeepLinkToRenderer(url);
if (mainWindow) {
@@ -76,14 +58,20 @@ if (!gotLock) {
});
}
// macOS-only: clicks on openswarm:// links fire this event (instead of
// relaunching the process).
// macOS-only: openswarm:// clicks fire this event instead of relaunching the process.
app.on('open-url', (event, url) => {
event.preventDefault();
forwardDeepLinkToRenderer(url);
if (mainWindow) mainWindow.focus();
});
// Windows AppUserModelID: required so native toast notifications fire
// instead of falling back to legacy balloon tips. Must be set BEFORE the
// first Notification is created. electron-builder also injects this at
// install time but setting it here defends against ad-hoc dev runs.
if (process.platform === 'win32') {
try { app.setAppUserModelId('com.openswarm.app'); } catch (_) {}
}
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
app.commandLine.appendSwitch('enable-gpu-rasterization');
@@ -95,14 +83,10 @@ let backendProcess = null;
let backendPort = null;
let cachedUpdateStatus = { status: 'idle', info: null, error: null };
// Splash boot UX. Opens immediately on app.whenReady so the user sees
// motion within ~1s of double-click instead of a 30-60s frozen icon
// while Python imports + Defender real-time scans warm up. Closed once
// mainWindow is `ready-to-show`. See electron/splash/splash.html.
let splashWindow = null;
let mainWindowReady = false;
let isQuittingFromSplash = false; // guards against double-quit during error shutdown
const recentBackendStderr = []; // ring buffer (last ~60 lines) for splash error UI
let isQuittingFromSplash = false;
const recentBackendStderr = [];
let splashDataUrlCache = null;
const isPackaged = app.isPackaged;
@@ -110,13 +94,7 @@ const isDev = process.env.ELECTRON_DEV === '1';
const iconPath = process.platform === 'win32'
? path.join(__dirname, 'build', 'icon.ico')
: path.join(__dirname, 'build', 'icon.png');
// PNG version of the icon for the splash. We ship a copy at splash/icon.png
// because electron-builder's `build/` directory is its inputs folder (used
// to GENERATE the .icns bundled icon) and is NOT included in the shipped
// asar archive — so `build/icon.png` exists in dev but ENOENTs in packaged
// builds. `splash/` IS shipped (alongside splash.html), so reading from
// there works in both modes. See the kept-in-sync copy command in the
// build scripts (or just commit both).
// build/ is electron-builder input, not in the asar; splash uses splash/icon.png.
const iconPngPath = path.join(__dirname, 'splash', 'icon.png');
function loadSplashDataUrl() {
@@ -146,16 +124,14 @@ function createSplashWindow() {
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true, // avoid duplicate taskbar entry next to mainWindow
skipTaskbar: true,
show: true,
center: true,
backgroundColor: '#0a0a10', // opaque to dodge Windows DWM transparency quirks
title: 'OpenSwarm',
icon: iconPath,
webPreferences: {
// Splash content is fully self-contained (data URL, no remote
// resources) so nodeIntegration here is safe and lets the splash
// listen on ipcRenderer directly without a separate preload.
// Splash is fully self-contained (data URL), so nodeIntegration is safe.
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
@@ -164,18 +140,11 @@ function createSplashWindow() {
});
w.setMenuBarVisibility(false);
w.loadURL(dataUrl);
// If the splash is dismissed BEFORE the main window has shown itself,
// treat that as the user intentionally bailing out of boot. Without
// this, splash.close() would silently leave a backend running with
// no UI, which is confusing and leaks the python process.
// The isQuittingFromSplash guard avoids a double-quit when the user
// clicked the splash's Quit button (which also calls app.quit) — that
// path closes the splash and would re-trigger this branch.
// Splash close before main window means user bailed; quit so backend doesn't leak.
w.on('closed', () => {
splashWindow = null;
if (!mainWindowReady && !isQuittingFromSplash) {
isQuittingFromSplash = true;
console.log('[splash] closed before main window appeared — quitting app');
try { if (!isDev) killBackend(); } catch (_) {}
app.quit();
}
@@ -189,17 +158,12 @@ function emitSplashStatus(payload) {
}
}
// OS-tailored status copy. The "first launch is slow" experience has very
// different causes per platform (Defender on Windows, Gatekeeper +
// XProtect notarization scan on macOS), and naming the actual culprit
// helps users feel like the wait is intentional rather than the app being
// broken. Used by the long-wait branches in waitForBackend below.
function osStillStartingText() {
if (process.platform === 'win32') {
return 'Still starting Windows Defender is scanning files (first launch only)…';
return 'Still starting, Windows Defender is scanning files (first launch only)…';
}
if (process.platform === 'darwin') {
return 'Still starting macOS is verifying the bundle (first launch only)…';
return 'Still starting, macOS is verifying the bundle (first launch only)…';
}
return 'Still starting (first launch is slower than subsequent launches)…';
}
@@ -213,16 +177,10 @@ function osTakingTooLongText() {
return 'Backend is taking longer than usual. You can wait, view logs, or restart.';
}
/**
* macOS GUI apps launched from Finder/Dock inherit a minimal PATH from launchd
* (/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin) none of the user's shell
* additions (nvm, volta, homebrew, bun, etc.) are present. Resolve the real
* PATH by asking the user's default shell, then fall back to well-known dirs.
*/
/** Resolves the user's real PATH; macOS GUI apps inherit only launchd's minimal PATH. */
function getShellPath() {
if (process.platform !== 'darwin' || isDev) return process.env.PATH || '';
// Strategy 1: ask the user's login shell for its PATH
try {
const userShell = process.env.SHELL || '/bin/zsh';
const result = execFileSync(userShell, ['-ilc', 'echo $PATH'], {
@@ -232,9 +190,8 @@ function getShellPath() {
});
const resolved = result.trim();
if (resolved) return resolved;
} catch (_) { /* fall through */ }
} catch (_) {}
// Strategy 2: read macOS system PATH config (/etc/paths + /etc/paths.d/*)
const systemPaths = [];
try {
const base = fs.readFileSync('/etc/paths', 'utf8');
@@ -242,7 +199,7 @@ function getShellPath() {
const p = line.trim();
if (p) systemPaths.push(p);
}
} catch (_) { /* ignore */ }
} catch (_) {}
try {
const pathsD = '/etc/paths.d';
if (fs.existsSync(pathsD)) {
@@ -254,9 +211,8 @@ function getShellPath() {
}
}
}
} catch (_) { /* ignore */ }
} catch (_) {}
// Strategy 3: well-known user-local bin directories
const home = os.homedir();
const fallbackDirs = [
path.join(home, '.local/bin'),
@@ -276,14 +232,14 @@ function getShellPath() {
fallbackDirs.unshift(path.join(nvmDir, versions[0], 'bin'));
}
}
} catch (_) { /* ignore */ }
} catch (_) {}
const seen = new Set();
const dirs = [];
for (const d of [...fallbackDirs, ...systemPaths, ...(process.env.PATH || '').split(':')]) {
if (!d || seen.has(d)) continue;
seen.add(d);
try { if (fs.statSync(d).isDirectory()) dirs.push(d); } catch { /* skip */ }
try { if (fs.statSync(d).isDirectory()) dirs.push(d); } catch {}
}
return dirs.join(':');
}
@@ -296,18 +252,7 @@ function getResourcePath(...segments) {
}
function getPythonPath() {
// python-build-standalone layout differs by OS:
// macOS / Linux: <env>/bin/python3
// Windows: <env>\python.exe (no bin/, no python3)
//
// macOS extra: invoke via Python.app/Contents/MacOS/python3 instead of
// bin/python3 so LaunchServices reads LSUIElement=1 from the wrapper
// bundle's Info.plist and skips the Dock entry. Without this, the
// bundleless python3.13 binary appears as a generic "exec" placeholder
// in the Dock on fresh user Macs, bouncing for the entire boot window.
// sys.prefix / sys.executable still resolve via realpath so all stdlib
// and site-packages discovery is unchanged. See scripts/build-python-env.sh
// for the wrapper layout invariants.
// macOS uses Python.app/Contents/MacOS/python3 so LSUIElement suppresses the Dock entry.
if (isPackaged) {
const envPath = path.join(process.resourcesPath, 'python-env');
if (process.platform === 'win32') {
@@ -315,9 +260,6 @@ function getPythonPath() {
}
if (process.platform === 'darwin') {
const wrapped = path.join(envPath, 'Python.app', 'Contents', 'MacOS', 'python3');
// Defensive fallback: if the wrapper is missing for any reason
// (e.g. older build cache), fall back to the bare binary so boot
// still succeeds — only the Dock-icon suppression is lost.
if (fs.existsSync(wrapped)) return wrapped;
}
return path.join(envPath, 'bin', 'python3');
@@ -328,22 +270,7 @@ function getPythonPath() {
return path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3');
}
// Path to a real Node.js binary bundled in extraResources, or null if not
// shipped (dev mode, or build that skipped the node-fetch step). Backend
// reads OPENSWARM_NODE_PATH env var to prefer this over both system `node`
// (which fresh user Macs lack) and the ELECTRON_RUN_AS_NODE fallback
// (which has flaky Dock behavior + slow cold-start). Used by 9Router and
// MCP bundle spawning.
//
// Layout shipped by scripts/build-app.sh:
// <resources>/node/arm64/bin/node
// <resources>/node/x64/bin/node
// Both arches are staged so a single extraResources entry covers
// publish-mode dual-arch builds without per-arch staging hooks; the
// runtime picks the matching one by process.arch. Wasted ~25 MB per
// DMG of cross-arch payload is the cost of avoiding electron-builder's
// per-arch beforePack complexity. Windows uses node.exe at the root of
// the per-arch subdir.
// Both arches staged to avoid per-arch beforePack hooks.
function getBundledNodePath() {
if (!isPackaged) return null;
const arch = process.arch === 'x64' ? 'x64' : (process.arch === 'arm64' ? 'arm64' : null);
@@ -354,12 +281,7 @@ function getBundledNodePath() {
return fs.existsSync(candidate) ? candidate : null;
}
// Polls /api/health/check until the backend answers 200, or the spawned
// python process exits non-zero (real failure). Never times out by wall
// clock — on a cold-Defender Windows install this can take several
// minutes the first time, and silently calling app.quit() would leave
// users staring at a vanished icon. Instead we surface progressive
// warnings on the splash so the wait feels intentional.
// Never wall-clock times out: cold-Defender Windows can take minutes.
function waitForBackend(port, opts = {}) {
const proc = opts.process || null;
const start = Date.now();
@@ -371,7 +293,7 @@ function waitForBackend(port, opts = {}) {
if (proc) {
proc.once('exit', (code) => {
// exit with code === null means we killed it ourselves (normal shutdown).
// code === null = we killed it ourselves (normal shutdown).
if (code !== 0 && code !== null) {
finish(reject, new Error(`Backend process exited with code ${code} during startup`));
}
@@ -411,13 +333,7 @@ function waitForBackend(port, opts = {}) {
});
}
// Race a port-range search against a 3-second wall clock. On most machines
// `getPort.makeRange(8324, 8424)` returns within milliseconds, but Windows
// EDR / corp-firewall stacks can intercept the bind() probes and stall each
// attempt for seconds — 100 attempts × multi-second stalls = "OpenSwarm is
// hung at startup." The fallback `getPort({ port: 0 })` lets the OS pick
// any free ephemeral port; we don't actually care about staying inside the
// 8324-range — the renderer reads the port via IPC, no hardcoded assumption.
// Windows EDR stalls each bind probe; if we don't get a preferred port in 3s, fall back to OS-assigned.
async function pickBackendPort() {
const PREFERRED_TIMEOUT_MS = 3000;
const preferred = getPort({ port: getPort.makeRange(8324, 8424) });
@@ -428,7 +344,7 @@ async function pickBackendPort() {
const winner = await Promise.race([preferred, timeout]);
clearTimeout(timeoutHandle);
if (winner !== null) return winner;
console.warn(`[boot] getPort.makeRange(8324,8424) stalled past ${PREFERRED_TIMEOUT_MS}ms falling back to OS-assigned port`);
console.warn(`[boot] getPort.makeRange(8324,8424) stalled past ${PREFERRED_TIMEOUT_MS}ms, falling back to OS-assigned port`);
return await getPort({ port: 0 });
}
@@ -441,11 +357,6 @@ async function startBackend() {
const shellPath = getShellPath();
// Identifies how this build was packaged. Read by the backend service
// client so the cloud can split installer-using customers from
// run-from-source developers in dashboards. Honors a build-time override
// (set in CI when producing platform installers) before falling back to
// OS-derived defaults.
let installMethod = process.env.OPENSWARM_INSTALL_METHOD;
if (!installMethod) {
if (!isPackaged) {
@@ -455,8 +366,6 @@ async function startBackend() {
} else if (process.platform === 'win32') {
installMethod = 'windows-setup';
} else if (process.platform === 'linux') {
// electron-builder produces AppImage by default for linux targets.
// Override at packaging time when building .deb / .rpm.
installMethod = 'appimage';
} else {
installMethod = 'unknown';
@@ -470,43 +379,24 @@ async function startBackend() {
OPENSWARM_PORT: String(backendPort),
OPENSWARM_ELECTRON_PATH: process.execPath,
OPENSWARM_INSTALL_METHOD: installMethod,
// Inject the app version so the Python backend can report it in the
// analytics envelope. Without this, _read_app_version() in
// service/service.py tries to read electron/package.json via a relative
// path that resolves correctly in `bash run.sh` dev mode but fails in
// packaged dmg/exe builds — which made every shipped install report
// app_version="unknown". The path-based fallback stays in place so this
// change is purely additive.
// Asar-relative reads fail in packaged builds, inject app version instead.
OPENSWARM_APP_VERSION: app.getVersion(),
// Inject the user's BCP 47 locale + IANA timezone. The Python backend
// doesn't have reliable APIs for either: locale.getdefaultlocale() is
// deprecated and inconsistent across OSes, and Python's local-tz string
// sometimes returns "PDT" or "Romance (zomertijd)" rather than
// "America/Los_Angeles". Electron has both in canonical form via
// app.getLocale() and Intl.DateTimeFormat().resolvedOptions().timeZone.
// Python's stdlib locale/tz are unreliable cross-OS, inject canonical BCP 47 + IANA.
OPENSWARM_LOCALE: app.getLocale(),
OPENSWARM_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
PYTHONDONTWRITEBYTECODE: '1',
// PEP 540 UTF-8 mode: makes open() default to UTF-8 on Windows where
// the locale is otherwise cp1252. Many backend modules read UTF-8
// .md / .json files without an explicit encoding= argument.
// PEP 540: force open() to UTF-8 on Windows (cp1252 otherwise).
PYTHONUTF8: '1',
};
// Tell the backend where to find a real Node binary for 9Router and
// bundled MCP servers. Preferring this over ELECTRON_RUN_AS_NODE avoids
// (a) the second OpenSwarm-as-Node process briefly registering in the
// Dock on fresh Macs, and (b) the slow Electron cold-start tail (~5-15s)
// that Electron-as-Node adds vs. native node (~1-2s). Falls back to the
// existing system-node / Electron-as-Node chain in nine_router._find_node()
// if the env var is unset (dev mode, or build without node fetch).
// Bundled node avoids a second Dock entry on fresh Macs and ~5-15s cold-start tail vs ELECTRON_RUN_AS_NODE.
const bundledNode = getBundledNodePath();
if (bundledNode) {
env.OPENSWARM_NODE_PATH = bundledNode;
}
if (isPackaged) {
// site-packages location differs by OS — Windows has no lib/python3.13/.
// Windows site-packages lives under Lib/, not lib/python3.13/.
const pythonEnvSitePackages = process.platform === 'win32'
? path.join(process.resourcesPath, 'python-env', 'Lib', 'site-packages')
: path.join(process.resourcesPath, 'python-env', 'lib', 'python3.13', 'site-packages');
@@ -530,9 +420,6 @@ async function startBackend() {
backendProcess.stdout.on('data', (data) => {
const text = data.toString();
process.stdout.write(`[backend] ${text}`);
// uvicorn prints this exact phrase once the ASGI app is live and
// routes are mounted — perfect milestone for the splash to flip
// from "starting backend" to "loading components".
if (text.indexOf('Application startup complete') !== -1) {
emitSplashStatus('Loading components…');
}
@@ -541,9 +428,6 @@ async function startBackend() {
backendProcess.stderr.on('data', (data) => {
const text = data.toString();
process.stderr.write(`[backend] ${text}`);
// Buffer the most recent stderr lines for the splash error UI so
// when boot fails we can show actionable context inline instead of
// making the user dig through a log file.
recentBackendStderr.push(text);
while (recentBackendStderr.length > 60) recentBackendStderr.shift();
});
@@ -561,16 +445,9 @@ async function startBackend() {
await waitForBackend(backendPort, { process: backendProcess });
console.log(`Backend ready on port ${backendPort}`);
// Backend writes a per-install auth token file at startup. Read it
// here so the renderer can include it in WS URLs (`?token=...`) and
// HTTP Authorization headers. Without this, any webpage loaded in
// any browser on the machine could hit our localhost API and
// impersonate the user. See backend/auth.py.
await loadAuthToken();
// Tray + workflow lifecycle. Tray stays resident so scheduled fires
// survive a window close; workflowsLifecycle polls /workflows/active
// every 5s to drive powerSaveBlocker, updater veto, and tray status.
// Tray must stay resident so schedules survive window close.
try {
tray.setup({ backendPort, authToken });
workflowsLifecycle.setBackend({ port: backendPort, token: authToken });
@@ -584,18 +461,11 @@ async function startBackend() {
}
}
// Per-install auth token read from <data-root>/auth.token (backend
// generates this at startup). Cached here so `get-auth-token` IPC
// calls are fast. If reads fail initially (race with backend) we
// retry a few times.
// Per-install bearer token; mirrors backend/config/paths.py.
let authToken = '';
function getAuthTokenFilePath() {
// Mirrors backend/config/paths.py. On macOS the file lives at
// ~/Library/Application Support/OpenSwarm/data/auth.token; on
// Windows under %APPDATA%/OpenSwarm/data/; on Linux under
// ~/.local/share/OpenSwarm/data/. In dev the backend writes it to
// backend/data/auth.token instead.
if (isPackaged) {
if (process.platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'auth.token');
@@ -606,15 +476,12 @@ function getAuthTokenFilePath() {
return path.join(xdg, 'OpenSwarm', 'data', 'auth.token');
}
}
// Dev: backend/data/auth.token relative to repo root.
return path.join(__dirname, '..', 'backend', 'data', 'auth.token');
}
async function loadAuthToken() {
const tokenPath = getAuthTokenFilePath();
// Retry up to 20 × 100ms = 2s in case backend is still writing the
// file. Backend writes BEFORE binding HTTP port though, so this
// usually returns on the first attempt.
// 20 * 100ms = 2s retry budget; backend writes the token before HTTP bind, so first-try almost always.
for (let attempt = 0; attempt < 20; attempt++) {
try {
const contents = fs.readFileSync(tokenPath, 'utf8').trim();
@@ -626,7 +493,7 @@ async function loadAuthToken() {
} catch (_) {}
await new Promise(r => setTimeout(r, 100));
}
console.warn(`[auth] FAILED to load auth token from ${tokenPath} after 2s WS/HTTP will be rejected`);
console.warn(`[auth] FAILED to load auth token from ${tokenPath} after 2s, WS/HTTP will be rejected`);
}
function createWindow() {
@@ -638,10 +505,7 @@ function createWindow() {
title: 'OpenSwarm',
icon: iconPath,
titleBarStyle: 'hiddenInset',
// Stay hidden until the renderer fires `ready-to-show`. The splash
// is what the user looks at; we swap it out for this window only
// once React has actually painted, avoiding the white-flash that
// Electron windows do during initial layout.
// Hidden until ready-to-show so the splash-to-main swap doesn't white-flash.
show: false,
backgroundColor: '#1a1a1f',
webPreferences: {
@@ -662,14 +526,7 @@ function createWindow() {
mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, params) => {
webPreferences.plugins = true;
webPreferences.enableBlinkFeatures = 'EncryptedMedia';
// Force our webview preload to attach for every <webview>, unconditionally.
// The alternative (reading window.openswarm.getWebviewPreloadPath() in
// BrowserCard's React code at module-eval time) raced against the
// preload's async contextBridge exposure — the resulting attribute on
// the <webview> element ended up empty, so no preload ran and our
// passkey shim never loaded. Setting webPreferences.preload here runs
// on every attach and can't be out-raced. Absolute path (not file://)
// is what webPreferences expects.
// Setting preload from React races contextBridge.expose and ends up empty, so force-attach here.
webPreferences.preload = path.join(__dirname, 'webview-preload.js');
try {
console.log('[openswarm:attach-webview] forced preload=', webPreferences.preload, 'src=', params.src);
@@ -683,9 +540,7 @@ function createWindow() {
mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id);
});
// Once the renderer has loaded, flush any deep-link URL we captured before
// the window existed (cold-launch via openswarm://). pendingDeepLink may
// be a string (legacy) OR a {channel, url} object (v1.0.26+ OAuth claims).
// Flush any cold-launch deep link captured before the window existed.
mainWindow.webContents.once('did-finish-load', () => {
if (pendingDeepLink) {
if (typeof pendingDeepLink === 'string') {
@@ -701,16 +556,7 @@ function createWindow() {
mainWindow = null;
});
// Window-blur / window-focus tracking — analytics signal for "user
// switched to another app" (temp-churn). The renderer captures these
// through the existing report() pipeline; we just emit IPC notices
// here so the React layer can timestamp them and forward to the
// local backend's /api/service/submit endpoint.
//
// Cadence: at most once every 2 seconds per direction. Without that
// throttle, dragging a window across desktops or having a popup steal
// focus generates a burst of blur/focus pairs that pollute analytics
// with noise.
// Throttle to 1 per 2s per direction; window drags otherwise spam blur/focus.
let _lastFocusEvent = 0;
const FOCUS_THROTTLE_MS = 2000;
const sendFocusEvent = (kind) => {
@@ -731,9 +577,7 @@ function sendToRenderer(channel, ...args) {
function setupAutoUpdater() {
if (!autoUpdater) return;
// Silent background updates: download on detect, install on next quit.
// The OS gates the install on main-process exit (can't replace a
// running .app / locked .exe), so an active session is never disrupted.
// Download on detect, install on quit: OS can't replace a running .app/.exe.
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
@@ -770,8 +614,7 @@ function setupAutoUpdater() {
console.log('Update check skipped:', err.message);
});
// Always-on users (lid never closes) miss the once-at-startup check
// above. Re-check every 4h; coalesces if a download is already cached.
// Always-on users (lid never closed) miss the once-at-startup check, so re-check every 4h.
setInterval(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.log('Periodic update check failed:', err.message);
@@ -783,16 +626,13 @@ function killBackend() {
if (backendProcess) {
console.log('Killing backend process...');
if (process.platform === 'win32') {
// Windows: Node's child.kill() only terminates the direct child, leaving
// grandchildren (e.g. the router node process the Python backend
// spawned) as orphans. Use `taskkill /T /F` to walk the process tree.
// child.kill() leaves grandchildren orphaned; taskkill /T /F walks the tree.
try {
require('child_process').execFileSync(
'taskkill', ['/PID', String(backendProcess.pid), '/T', '/F'],
{ stdio: 'ignore' },
);
} catch (_) {
// taskkill failed (process may have already exited) — fall back to kill().
try { backendProcess.kill(); } catch (_) {}
}
} else {
@@ -808,10 +648,7 @@ function killBackend() {
}
app.whenReady().then(async () => {
// Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's
// in argv; macOS fires open-url AFTER whenReady which we handle above)
// route through forwardDeepLinkToRenderer so the URL gets stashed under
// its correct IPC channel (auth-url vs oauth-claim).
// Cold-launch openswarm:// arrives in argv on Windows/Linux (macOS uses open-url instead).
const initialDeepLink = extractOpenswarmUrl(process.argv);
if (initialDeepLink) forwardDeepLinkToRenderer(initialDeepLink);
@@ -838,8 +675,7 @@ app.whenReady().then(async () => {
return allowed.includes(permission);
});
// Read-only logging for DRM license requests — no modifying interceptors
// so the network stack can set Content-Type and other headers normally.
// Read-only logging; modifying interceptors break Widevine header set-up.
session.defaultSession.webRequest.onSendHeaders(
{ urls: ['*://*/*widevine*license*'] },
(details) => {
@@ -864,18 +700,9 @@ app.whenReady().then(async () => {
},
);
// Splash window opens immediately so the user sees motion within ~1s
// of double-clicking. Without this, on a cold-Defender Windows install
// the dock/taskbar icon flashes for 30-60s with nothing visible.
splashWindow = createSplashWindow();
emitSplashStatus('Starting OpenSwarm…');
// Widevine CDM and backend startup are independent — run them
// concurrently. Backend is the long pole on Windows (Defender + Python
// cold start), so we don't want a slow CDM download to add seconds to
// every boot. Webviews that need DRM still wait on `components.whenReady`
// before loading via the existing webview-preload flow, so parallelizing
// here is safe.
let widevinePromise;
if (components && typeof components.whenReady === 'function') {
widevinePromise = components.whenReady().then(
@@ -888,7 +715,7 @@ app.whenReady().then(async () => {
(err) => { console.warn('Widevine CDM not available:', err && err.message); }
);
} else {
console.log('CastLabs components API not available using standard Electron (no DRM)');
console.log('CastLabs components API not available, using standard Electron (no DRM)');
widevinePromise = Promise.resolve();
}
@@ -901,10 +728,21 @@ app.whenReady().then(async () => {
await startBackend();
}
emitSplashStatus('Almost ready…');
createWindow();
// Hidden-launch: --hidden arg (set by workflowsLifecycle.setLoginItem
// on Windows + Linux; macOS uses openAsHidden) means skip the main
// window so tray + scheduler run in background. User enabled
// "Always-on" -> app boots invisibly.
const launchedHidden = process.argv.includes('--hidden');
if (!launchedHidden) {
createWindow();
} else if (splashWindow && !splashWindow.isDestroyed()) {
isQuittingFromSplash = false;
try { splashWindow.destroy(); } catch (_) {}
splashWindow = null;
}
if (!isDev) {
setupAutoUpdater();
mainWindow.webContents.on('did-finish-load', () => {
if (mainWindow) mainWindow.webContents.on('did-finish-load', () => {
if (cachedUpdateStatus.status === 'available') {
sendToRenderer('update-available', cachedUpdateStatus.info);
} else if (cachedUpdateStatus.status === 'downloaded') {
@@ -913,17 +751,13 @@ app.whenReady().then(async () => {
});
}
// Swap splash → main only once React has actually painted. ready-to-show
// fires after the renderer's first frame, eliminating the white-flash
// that would otherwise pop between splash close and React mount.
// Swap on ready-to-show (post-first-paint); avoids white-flash on mount.
if (mainWindow) {
const swapToMain = () => {
if (mainWindowReady || mainWindow.isDestroyed()) return;
mainWindowReady = true;
try { mainWindow.show(); mainWindow.focus(); } catch (_) {}
// Tiny delay so the OS gets a chance to bring main to front
// before splash disappears — avoids a single-frame "no window"
// gap on Windows.
// 120ms gap lets the OS raise main before splash hides; avoids a single-frame gap on Windows.
setTimeout(() => {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.destroy();
@@ -932,25 +766,15 @@ app.whenReady().then(async () => {
}, 120);
};
mainWindow.once('ready-to-show', swapToMain);
// Fallback: if the renderer fails to load (e.g. dev server not
// running on localhost:3000), `ready-to-show` never fires and
// the splash would hang forever. Show main anyway so the dev
// sees the load error in the window itself.
// ready-to-show never fires if renderer load fails (dev server down); swap anyway so error is visible.
mainWindow.webContents.once('did-fail-load', (_e, errorCode, errorDescription, validatedURL) => {
console.warn('[boot] mainWindow load failed:', errorCode, errorDescription, validatedURL);
if (isDev) swapToMain();
});
}
// Don't block on Widevine; it'll resolve in the background. Logged above.
widevinePromise.catch(() => {});
// Affiliate / referral handshake. On the very first launch, opens the
// landing page's /welcome handler in the user's default browser so the
// browser (which holds the install_token from the click on the
// download CTA) can pair our app_install_id with the referral code.
// No-op on every subsequent launch, no-op in dev unless forced. Fire
// and forget, never blocks UI startup. See electron/affiliateTracking.js.
affiliateTracking.maybeRunFirstLaunchHandshake({
shell,
userDataDir: app.getPath('userData'),
@@ -961,36 +785,18 @@ app.whenReady().then(async () => {
});
} catch (err) {
console.error('Failed to start:', err);
// Surface the failure on the splash instead of silently quitting.
// The user picks: view logs, restart, or quit. This eliminates the
// class of "I clicked OpenSwarm and nothing happened" reports.
// Do NOT app.quit(); user picks the next step from the splash actions.
emitSplashStatus({
text: "OpenSwarm couldn't start: " + (err && err.message ? err.message : String(err)),
level: 'error',
showActions: true,
logs: recentBackendStderr.slice(-30).join(''),
});
// Do NOT call app.quit() here — the user controls the next step
// through the splash action buttons.
}
});
app.on('web-contents-created', (_event, contents) => {
// Override the user-agent on popup BrowserWindows (i.e. anything created
// via window.open from the renderer, which includes the OAuth popup for
// subscription connect flows). Electron's default UA includes an
// `Electron/X.Y.Z` token that accounts.google.com blacklists with a
// "browser not supported" page — and auth.openai.com is similarly picky.
// Spoofing a current Chrome UA makes those identity providers treat the
// popup like a real browser without changing the flow OpenSwarm uses to
// capture the callback (window.open + postMessage).
//
// This check runs synchronously during `new BrowserWindow()` construction.
// On the very first invocation (for mainWindow itself), `mainWindow` is
// still null because assignment happens after the constructor returns,
// so the `mainWindow &&` short-circuits and we leave the main window's
// UA alone. Webview tags report `getType() === 'webview'` and are also
// skipped — they render user-visited sites and must advertise the real UA.
// Google/OpenAI auth pages blacklist Electron UA, so spoof Chrome on popups.
if (
contents.getType() === 'window' &&
mainWindow &&
@@ -1012,19 +818,7 @@ app.on('web-contents-created', (_event, contents) => {
return { action: 'deny' };
}
// Note on which providers still use this popup path:
// - Anthropic/Claude: still works here with the Chrome UA override above.
// - Google (Gemini, Antigravity): blocks embedded browsers wholesale
// ("browser not supported"), even with UA spoofing + sandboxed
// partition + navigator.webdriver patches. Routes through
// shell.openExternal instead.
// - OpenAI/Codex: now also routes through shell.openExternal — the
// embedded popup renders blank for some users (newer embed
// detection + regional access checks), and the system browser
// surfaces the actual error.
// See _EXTERNAL_BROWSER_PROVIDERS in backend/apps/nine_router.py.
// When Anthropic adds the same checks, add "claude" there too.
// Anthropic still works here; Google/OpenAI route via shell.openExternal (see _EXTERNAL_BROWSER_PROVIDERS).
return {
action: 'allow',
overrideBrowserWindowOptions: {
@@ -1044,21 +838,12 @@ app.on('web-contents-created', (_event, contents) => {
contents.on('did-create-window', (childWindow) => {
if (mainWindow && !mainWindow.isDestroyed() && !childWindow.isDestroyed()) {
childWindow.setParentWindow(mainWindow);
// Belt-and-suspenders: if the parent was fullscreen when window.open
// fired, Electron can still spawn the child fullscreen. Force it back.
// Electron can spawn child fullscreen if parent was; force out.
if (childWindow.isFullScreen()) childWindow.setFullScreen(false);
}
});
// OAuth callback URL interception. The npm `9router` package's /callback
// page relays the code back via window.opener.postMessage — which
// silently no-ops on some flows (e.g. Anthropic's Claude Code auth pages
// that reset the opener chain across cross-origin redirects). Capturing
// the URL at the navigation layer is format-agnostic and works regardless
// of whether the relay via postMessage/BroadcastChannel/localStorage made
// it back to the renderer. Same code+state then gets forwarded to the
// main window via IPC, where Settings.tsx picks it up and calls
// /api/agents/subscriptions/exchange.
// postMessage relay fails on cross-origin redirects, intercept at the navigation layer.
const forwardOauthCallback = (url) => {
try {
const u = new URL(url);
@@ -1072,7 +857,7 @@ app.on('web-contents-created', (_event, contents) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('openswarm:oauth-callback', { code, state, error });
}
} catch { /* not a URL we care about */ }
} catch {}
};
contents.on('did-navigate', (_e, url) => forwardOauthCallback(url));
contents.on('did-redirect-navigation', (_e, url) => forwardOauthCallback(url));
@@ -1090,24 +875,13 @@ app.on('web-contents-created', (_event, contents) => {
}
});
// -----------------------------------------------------------------
// CDP debugger auto-attach for browser sub-agent accessibility tree
// -----------------------------------------------------------------
// The browser sub-agent uses Chrome DevTools Protocol (specifically
// Accessibility.getFullAXTree, DOM.resolveNode, Input.dispatchMouseEvent)
// to perceive and act on hostile sites where CSS selectors fail. CDP
// commands require webContents.debugger.attach() which is only callable
// from the main process. We attach lazily on first use rather than at
// creation time — that avoids the "Another debugger is already attached"
// race when DevTools is opened on the webview.
// Lazy attach avoids races with DevTools.
try {
contents.debugger.on('detach', (_e, reason) => {
console.log(`[cdp] detach on wcId ${contents.id}: ${reason}`);
cdpAxIndexCache.delete(contents.id);
});
} catch (e) {
// Older Electron may not have the listener API; non-fatal.
}
} catch (e) {}
contents.on('destroyed', () => {
cdpAxIndexCache.delete(contents.id);
@@ -1119,14 +893,7 @@ app.on('web-contents-created', (_event, contents) => {
cdpQueueByWcId.delete(contents.id);
});
// WebAuthn/passkey shim. Injected on every dom-ready in the main world
// via executeJavaScript (which uses V8's direct evaluation path and
// bypasses Trusted Types CSP — inline <script> injection from the
// webview preload was being blocked on accounts.google.com because of
// `require-trusted-types-for 'script'`). The shim overrides
// navigator.credentials so passkey calls reject cleanly and post a
// tagged message back; webview-preload.js listens and forwards to the
// embedder, which surfaces the "Passkeys aren't supported" dialog.
// Inject on dom-ready in main world; bypasses Trusted Types CSP that blocks inline <script>.
contents.on('dom-ready', () => {
contents.executeJavaScript(`
(function() {
@@ -1190,7 +957,6 @@ app.on('web-contents-created', (_event, contents) => {
return resp;
};
// Check EME availability
if (navigator.requestMediaKeySystemAccess) {
navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
initDataTypes: ['cenc'],
@@ -1211,11 +977,7 @@ app.on('web-contents-created', (_event, contents) => {
});
app.on('window-all-closed', () => {
// With the tray resident, closing the last window must NOT quit the
// process. Backend keeps running, scheduler keeps firing, and the user
// can quit explicitly from the tray menu. If tray init failed (rare),
// fall back to the legacy quit-on-close behavior so the app doesn't
// become a zombie process.
// Scheduler must keep firing after windows close; quit only if tray init failed.
if (tray.isEnabled()) return;
if (!isDev) killBackend();
app.quit();
@@ -1223,10 +985,7 @@ app.on('window-all-closed', () => {
let drainingForQuit = false;
app.on('before-quit', async (event) => {
// If a scheduled run is in flight, give it up to 30s to finish before
// we kill the backend. Skipping the drain destroys real work the user
// paid LLM cost for. The `drainingForQuit` guard prevents the timer
// from being re-armed on the second event Electron fires.
// Drain in-flight runs (up to 30s) so we don't destroy paid LLM work.
if (drainingForQuit) return;
try {
const active = await workflowsLifecycle.getActive();
@@ -1252,24 +1011,14 @@ app.on('activate', () => {
}
});
// Splash window action buttons. Only meaningful while splashWindow is alive
// (during boot or in the post-failure error state). Sent via ipcRenderer.send
// from electron/splash/splash.html.
ipcMain.on('splash:action', (_event, action) => {
if (action === 'quit') {
isQuittingFromSplash = true;
app.quit();
} else if (action === 'restart') {
// app.relaunch + app.exit is the canonical Electron restart pattern.
// killBackend runs via the will-quit listener so the python child
// gets cleaned up before we re-spawn ourselves.
app.relaunch();
app.exit(0);
} else if (action === 'open-logs') {
// No backend log file is written to disk today; the next-best thing
// is opening the OpenSwarm data dir, where the user can see the
// auth.token file and any future log artifacts. Surfacing the dir
// also lets advanced users self-serve (clear data, etc).
try {
const dataDir = path.dirname(getAuthTokenFilePath());
shell.openPath(dataDir).catch(() => {});
@@ -1279,10 +1028,7 @@ ipcMain.on('splash:action', (_event, action) => {
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('get-auth-token', () => {
// Re-read the file every time. The backend rotates the token on each
// start, and during dev hot-reload the cached value could go stale
// while the renderer stays alive. Re-reading is cheap (small file,
// OS caches it) and guarantees the renderer never holds a dead token.
// Backend rotates token per start; cached value goes stale across dev hot-reload.
try {
const p = getAuthTokenFilePath();
const current = fs.readFileSync(p, 'utf8').trim();
@@ -1297,9 +1043,6 @@ ipcMain.handle('get-webview-preload-path', () => {
ipcMain.handle('get-update-status', () => cachedUpdateStatus);
// Workflow-lifecycle IPCs. The renderer uses these to drive the app-open
// status badge on the schedule editor and the "Fix" affordance that
// turns OpenSwarm into an always-on host with one click.
ipcMain.handle('workflows:get-app-open-info', () => ({
alwaysOn: workflowsLifecycle.getLoginItem() && tray.isEnabled(),
loginAtLaunch: workflowsLifecycle.getLoginItem(),
@@ -1341,11 +1084,7 @@ ipcMain.handle('download-update', async () => {
ipcMain.handle('install-update', async () => {
if (!autoUpdater) return { installed: false, queued: false };
// Check active workflows first. If any run is in flight, queue the
// install instead of letting quitAndInstall destroy the agent session.
// workflowsLifecycle's 5s poll fires the deferred install once active
// drains. Controlled by OPENSWARM_UPDATER_VETO so the feature can be
// disabled in case the veto loop misbehaves in the wild.
// Veto while workflow is in flight; lifecycle poller fires deferred install once active drains.
const vetoEnabled = process.env.OPENSWARM_UPDATER_VETO !== '0';
if (vetoEnabled) {
try {
@@ -1371,9 +1110,6 @@ ipcMain.handle('open-external', (_event, url) => {
}
});
// Affiliate install state. Returns the persisted install.json contents so
// the renderer can attach the referral code to authenticated cloud calls
// (Stripe checkout, sign-in events) for downstream attribution.
ipcMain.handle('get-install-state', () => {
try {
return affiliateTracking._readState(app.getPath('userData'));
@@ -1382,19 +1118,10 @@ ipcMain.handle('get-install-state', () => {
}
});
// ---------------------------------------------------------------------------
// CDP debugger bridge for the browser sub-agent
// ---------------------------------------------------------------------------
// Maintains a per-webContents AX index cache (numeric index → backendNodeId)
// and serializes CDP commands per target so concurrent calls don't interleave.
// The renderer calls window.openswarm.sendCdpCommand(wcId, method, params),
// which routes through this handler to webContents.debugger.sendCommand().
const cdpAxIndexCache = new Map(); // wcId -> Map<index, backendNodeId>
const cdpQueueByWcId = new Map(); // wcId -> Promise (serialization tail)
const cdpAxIndexCache = new Map();
const cdpQueueByWcId = new Map();
function getWebContentsById(wcId) {
// webContents is exposed as a top-level Electron API
const { webContents } = require('electron');
return webContents.fromId(wcId);
}
@@ -1407,16 +1134,14 @@ async function ensureDebuggerAttached(wc) {
try {
wc.debugger.attach('1.3');
} catch (err) {
// Re-raise as a clean error string for the renderer.
throw new Error(`debugger.attach failed: ${err.message || err}`);
}
}
async function sendCdpCommandSerialized(wcId, method, params) {
// Chain on the per-wcId queue so concurrent renderer calls run in order.
const prev = cdpQueueByWcId.get(wcId) || Promise.resolve();
const next = prev
.catch(() => {}) // never let a previous failure poison the chain
.catch(() => {})
.then(async () => {
const wc = getWebContentsById(wcId);
if (!wc || wc.isDestroyed()) {
@@ -1429,7 +1154,6 @@ async function sendCdpCommandSerialized(wcId, method, params) {
try {
return await next;
} finally {
// If we're still the tail of the queue, clear it so the map doesn't grow.
if (cdpQueueByWcId.get(wcId) === next) {
cdpQueueByWcId.delete(wcId);
}
@@ -1445,9 +1169,6 @@ ipcMain.handle('send-cdp-command', async (_event, wcId, method, params) => {
}
});
// Renderer-side AX index cache helpers — the renderer stores its own copy
// keyed by (browser_id, tab_id). The main process only stores per-wcId for
// invalidation purposes.
ipcMain.handle('cdp-cache-set', (_event, wcId, indexMap) => {
cdpAxIndexCache.set(wcId, indexMap || {});
return { ok: true };
@@ -1478,9 +1199,7 @@ ipcMain.handle('connect-slack', async () => {
},
});
// Override the global window-open handler so new tabs/windows from Slack
// (e.g. workspace redirects) navigate this popup instead of getting
// hijacked into a dashboard browser card.
// Re-navigate popup instead of routing to dashboard browser cards.
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
win.loadURL(url).catch(() => {});
@@ -1488,9 +1207,7 @@ ipcMain.handle('connect-slack', async () => {
return { action: 'deny' };
});
// Block slack:// deep-link attempts (they'd try to launch the native app
// and fail). Slack always falls through to a web URL after the deep link
// fails, so just swallow these.
// slack:// tries to launch the native app; swallow it so Slack falls through to a web URL.
win.webContents.on('will-navigate', (event, url) => {
if (url.startsWith('slack://')) {
event.preventDefault();
@@ -1534,16 +1251,13 @@ ipcMain.handle('connect-slack', async () => {
const cookies = await win.webContents.session.cookies.get({ url: 'https://slack.com' });
const dCookie = cookies.find((c) => c.name === 'd');
if (dCookie && dCookie.value) {
// The d cookie value may or may not already include the xoxd- prefix
// depending on how Slack encodes it. Normalize it.
// Slack may or may not pre-prefix the d cookie with xoxd-, normalize.
const raw = decodeURIComponent(dCookie.value);
const cookie = raw.startsWith('xoxd-') ? raw : `xoxd-${raw}`;
finish(resolve, { token, cookie });
}
}
} catch (_) {
// page navigating, ignore
}
} catch (_) {}
}, 1000);
const timeoutHandle = setTimeout(() => {
+7
View File
@@ -104,6 +104,13 @@ const { contextBridge, ipcRenderer } = require('electron');
enableTray: (_value) => Promise.resolve(true),
getActiveRuns: () => ipcRenderer.invoke('workflows:get-active'),
notify: (payload) => ipcRenderer.invoke('workflows:notify', payload),
// Subscribed by WebSocketManager so notification button clicks
// (Looks good / Re-run / Adjust / Open) route back into the app.
onNotificationAction: (cb) => {
const listener = (_event, payload) => cb(payload);
ipcRenderer.on('workflow:notification-action', listener);
return () => ipcRenderer.removeListener('workflow:notification-action', listener);
},
// OAuth popup callback. Fires when any child webContents navigates to
// localhost:20128/callback?code=... — main.js watches for this and
+25 -13
View File
@@ -1,11 +1,4 @@
// Menubar tray for OpenSwarm. Keeps the app resident while the user
// closes the main window, so scheduled workflows still fire. Owned by
// main.js; this module exports a single setup() that returns the Tray
// instance plus a status updater.
//
// Icon assets live under electron/assets/tray-{idle,running,paused}.png.
// They are templated on macOS so the menubar respects light/dark mode
// without two separate sets.
// Menubar tray that keeps the app resident after window close so scheduled workflows still fire.
const { app, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
@@ -17,11 +10,15 @@ let backendPortRef = null;
let authTokenRef = null;
function iconPath(state) {
const base = path.join(__dirname, 'assets', `tray-${state}.png`);
// We don't crash on missing icons; nativeImage returns an empty image
// and Electron still renders a fallback. Avoids hard-failing the
// packaged build if assets aren't bundled yet.
return base;
// Windows tray prefers .ico (multi-res, crisp at any DPI); falls back to PNG if not shipped.
if (process.platform === 'win32') {
const ico = path.join(__dirname, 'assets', `tray-${state}.ico`);
try {
const fs = require('fs');
if (fs.existsSync(ico)) return ico;
} catch (_) {}
}
return path.join(__dirname, 'assets', `tray-${state}.png`);
}
function postPause(value) {
@@ -97,6 +94,21 @@ function setup({ backendPort, authToken }) {
trayInstance.setToolTip('OpenSwarm: idle');
enabled = true;
rebuildMenu({ activeTitle: null, paused: false });
// Cross-platform click parity: on macOS the context menu opens on
// left-click automatically; on Windows/Linux left-click does
// nothing by default. We attach a click handler that opens the
// window on left-click (a Windows-typical pattern) so users on
// those platforms aren't confused when the tray "doesn't work."
if (process.platform !== 'darwin') {
trayInstance.on('click', () => {
const { BrowserWindow } = require('electron');
const wins = BrowserWindow.getAllWindows();
if (wins[0]) { wins[0].show(); wins[0].focus(); }
else { app.emit('activate'); }
});
// Right-click already shows the context menu on Windows/Linux
// (Electron default), no extra wiring needed.
}
} catch (_) {
trayInstance = null;
enabled = false;
+7 -81
View File
@@ -1,22 +1,14 @@
/**
* Webview preload script patches browser fingerprinting so sites like
* Spotify/Netflix don't detect an Electron shell and disable features.
* Loaded via the webview's `preload` attribute before any page script runs.
*/
/** Webview preload: patches fingerprinting so sites like Spotify/Netflix don't detect Electron. */
'use strict';
// Diagnostic marker so we can confirm the preload actually attached to
// this webview. Surfaces via main.js's console-message listener.
try { console.warn('[openswarm:webview-preload] loaded for', window.location.href); } catch (_) {}
// Hide webdriver flag
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
configurable: true,
});
// Spoof navigator.plugins (Chrome has a few built-in ones)
const fakePlugins = {
0: { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
1: { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
@@ -41,7 +33,6 @@ try {
});
} catch (_) {}
// Ensure window.chrome exists (sites test for it)
if (!window.chrome) {
window.chrome = {};
}
@@ -53,7 +44,6 @@ if (!window.chrome.runtime) {
};
}
// Ensure navigator.languages has sensible values
try {
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en'],
@@ -61,7 +51,6 @@ try {
});
} catch (_) {}
// Patch permissions.query to report 'granted' for common permissions
const originalQuery = navigator.permissions?.query?.bind(navigator.permissions);
if (originalQuery) {
navigator.permissions.query = (params) => {
@@ -74,7 +63,6 @@ if (originalQuery) {
};
}
// Prevent iframe detection heuristics
try {
Object.defineProperty(document, 'hidden', {
get: () => false,
@@ -86,48 +74,14 @@ try {
});
} catch (_) {}
// Fix console.debug detection (some sites use it as a breakpoint detector)
// console.debug existence is used as a breakpoint detector by some sites.
const noop = () => {};
if (!window.console.debug) window.console.debug = noop;
// ---------------------------------------------------------------------------
// Passkey / WebAuthn handling
//
// Electron webviews can't trigger the OS platform authenticator (Touch ID,
// Windows Hello) — see electron/electron#15404, #24573. Sites that offer
// "Sign in with passkey" either fail silently or loop (#41472 on LinkedIn).
//
// With contextIsolation on (the Electron default), any patches we make to
// navigator.credentials from this preload only apply in the ISOLATED world;
// the page's own JS runs in the MAIN world and sees the original API. We
// have to inject the shim via webFrame.executeJavaScript so it lands in
// the page's JS context, then bridge the event back out with a DOM
// CustomEvent that this isolated-world preload listens for and relays via
// ipcRenderer.sendToHost to the embedding <webview> element.
//
// Two-pronged shim (both evaluated in the main world):
// 1. Probe APIs (isUserVerifyingPlatformAuthenticatorAvailable,
// isConditionalMediationAvailable) return false so sites that check
// before rendering a passkey button fall back to passwords quietly.
// 2. credentials.get / credentials.create with publicKey options reject
// with a clean NotAllowedError AND dispatch the passkey event so the
// embedder can surface a dialog. Conditional mediation (silent
// autofill) is intercepted but doesn't fire the dialog — that's
// not a user click.
// ---------------------------------------------------------------------------
// Webviews can't reach the OS authenticator (electron#15404, #24573); relay tagged postMessage from main-world shim out to embedding <webview>.
try {
const { ipcRenderer } = require('electron');
// The actual WebAuthn shim is injected by the MAIN process via
// contents.executeJavaScript on each 'dom-ready' (see electron/main.js).
// That path runs in the page's main world and bypasses Trusted Types
// CSP enforcement, which blocks our previous inline-<script> approach
// on sites like accounts.google.com.
//
// Our only job here is to act as the postMessage→IPC bridge: the main-
// world shim posts a tagged message, we relay it via sendToHost to the
// embedding <webview> element, which shows the "passkeys not supported"
// dialog.
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data && event.data.__openswarm__ === '__openswarm_passkey__') {
@@ -136,21 +90,7 @@ try {
}
});
// ---------------------------------------------------------------------------
// Canvas zoom passthrough (ctrl/meta + wheel)
//
// A <webview> is an out-of-process Chromium guest; wheel events that
// originate inside it never bubble to the embedding renderer. Without
// intercepting here, ctrl+wheel over a browser card just zooms the
// embedded page (Chromium's default) and the dashboard canvas never
// sees the gesture — issue #27.
//
// Capture-phase + passive:false so we run before the page's own listeners
// and can preventDefault to suppress the in-page page-zoom. We then
// forward the gesture (deltaY + guest-local cursor coords) to the host
// via sendToHost; BrowserCard's ipc-message handler turns it back into a
// synthetic WheelEvent dispatched from the webview element, which bubbles
// naturally to useCanvasControls' wheel listener.
// Webview wheel events don't bubble out; forward ctrl+wheel to host so canvas zoom works (issue #27).
const onWheelCapture = (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
@@ -171,25 +111,11 @@ try {
console.warn('[openswarm:webview-preload] sendToHost failed', err);
}
};
// Listen on both window and document in capture phase so we run before any
// page-level handler that might swallow the event. passive:false is required
// to call preventDefault on a wheel event.
// Capture-phase + passive:false so we run before page handlers and can preventDefault.
window.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });
document.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });
// ---------------------------------------------------------------------------
// [FRONTEND] console capture for the App Builder Terminal pane.
//
// Wrap window.console.{log,warn,error,info,debug} so each call also goes
// out via ipcRenderer.sendToHost('webview-console', {level, text}). The
// embedding <webview> element's ipc-message listener in ViewPreview
// forwards these to ViewEditor, which interleaves them with [BACKEND]
// lines coming over the runtime WS.
//
// Stringify args defensively — most console.log calls pass primitives or
// objects, but a thrown Error has a stack we want, and circular objects
// would blow up JSON.stringify. Fall back to String() for everything
// that won't serialize cleanly.
// Forwards console.* to host for App Builder Terminal pane.
const _stringifyArg = (a) => {
if (a === null) return 'null';
if (a === undefined) return 'undefined';
@@ -210,7 +136,7 @@ try {
try {
const text = args.map(_stringifyArg).join(' ');
ipcRenderer.sendToHost('webview-console', { level, text });
} catch (_) { /* never break the page's own logging */ }
} catch (_) {}
try { return orig.apply(this, args); } catch (_) {}
};
}
+42 -8
View File
@@ -125,16 +125,38 @@ function drainOnQuit(maxSeconds = 30) {
}
// Native OS notification. Falls back silently when Notification isn't
// supported (some Linux setups, headless test envs).
function showNativeNotification({ title, body, deepLink }) {
// supported (some Linux setups, headless test envs). When `actions` is
// provided AND we're on macOS, attaches button actions so the user can
// ack/re-run/open without the app taking focus. Routes the chosen
// outcome back to the renderer via an IPC channel that the renderer's
// WebSocketManager already listens for.
function showNativeNotification({ title, body, deepLink, runId, workflowId, actions }) {
if (!Notification || !Notification.isSupported()) return null;
try {
const n = new Notification({ title: title || 'OpenSwarm', body: body || '', silent: false });
if (deepLink) {
n.on('click', () => {
const opts = { title: title || 'OpenSwarm', body: body || '', silent: false };
const platformActions = Array.isArray(actions) && process.platform === 'darwin'
? actions.map((a) => ({ type: 'button', text: a.text }))
: undefined;
if (platformActions && platformActions.length) opts.actions = platformActions;
const n = new Notification(opts);
const route = (outcome) => {
try {
const { BrowserWindow } = require('electron');
const wins = BrowserWindow.getAllWindows();
const wc = wins[0]?.webContents;
if (wc) wc.send('workflow:notification-action', { outcome, runId, workflowId, deepLink });
} catch (_) {}
};
n.on('action', (_event, idx) => {
const a = (actions || [])[idx];
if (a) route(a.outcome);
});
n.on('click', () => {
if (deepLink) {
try { shell.openExternal(deepLink); } catch (_) {}
});
}
}
route('open');
});
n.show();
return n;
} catch (_) {
@@ -153,7 +175,19 @@ function getLoginItem() {
function setLoginItem(value) {
try {
app.setLoginItemSettings({ openAtLogin: Boolean(value), openAsHidden: true });
// openAsHidden is macOS-only; on Windows the equivalent is passing
// a --hidden arg and having main.js suppress the initial window
// when the arg is present. Linux uses a .desktop file in
// ~/.config/autostart/ which Electron writes for us via this same
// call (no extra plumbing needed).
const opts = {
openAtLogin: Boolean(value),
openAsHidden: true,
};
if (process.platform === 'win32') {
opts.args = ['--hidden'];
}
app.setLoginItemSettings(opts);
return Boolean(value);
} catch (_) { return false; }
}
+6 -65
View File
@@ -20,7 +20,6 @@ import {
import AppShell from './components/Layout/AppShell';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import ErrorBoundary from './components/ErrorBoundary';
// Lazy: heavy pages that aren't on the first-paint path.
const Skills = lazy(() => import('./pages/Skills/Skills'));
const Tools = lazy(() => import('./pages/Tools/Tools'));
const Modes = lazy(() => import('./pages/Modes/Modes'));
@@ -32,20 +31,7 @@ const OnboardingRoot = lazy(() =>
);
const SignInGate = lazy(() => import('./components/SignInGate'));
// Idle-prefetch the lazy page chunks so first-click on any sidebar
// entry doesn't pay 200-600ms for the webpack chunk download. Each
// `void import('...')` triggers webpack to stream the chunk in the
// background; React.lazy returns the cached module instantly when the
// user finally navigates. We do them sequentially inside one idle
// callback to avoid all six firing at once and contending for network
// + parse time during first paint.
if (typeof window !== 'undefined') {
// Map sidebar paths to their dynamic imports so a hover/mouseenter on
// the sidebar can preload the chunk before the click. By the time the
// user actually clicks (~100-300ms after hover), the chunk is parsed
// and React.lazy resolves instantly. Exposed on window so AppShell
// can call it without prop-drilling. Each entry is idempotent;
// webpack dedupes repeated dynamic imports.
(window as any).__openswarmPrefetchRoute = (path: string) => {
switch (path) {
case '/skills': void import('./pages/Skills/Skills'); return;
@@ -66,12 +52,6 @@ if (typeof window !== 'undefined') {
void import('./pages/Customization/Customization');
void import('./pages/Analytics/Analytics');
};
// Tighter idle deadline (was 4000ms): we WANT these chunks loaded
// before the user's first click, so don't let the browser defer them
// indefinitely. Fallback timeout reduced from 2000ms to 500ms for the
// same reason. The cost during initial render is small (one chunk
// parse per route, deferred); the cost of paying it on first click
// is a multi-hundred-ms freeze.
const ric = (window as any).requestIdleCallback as
| ((cb: () => void, opts?: { timeout?: number }) => number)
| undefined;
@@ -216,11 +196,7 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useDeepLink();
// Window blur/focus → analytics events (temp-churn signal).
useWindowFocus();
// Single global interaction-timestamp recorder. Powers idle-dim and
// similar UX, and gives the session-close dump a real "last user
// interaction" timestamp.
useInteractionHeartbeat();
return <>{children}</>;
};
@@ -233,23 +209,13 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
// Reconcile OpenSwarm Pro state with Stripe on every launch so a
// missed webhook (cancel, upgrade, renewal) can't leave the user
// wedged on stale info. Fire-and-forget; if the cloud is unreachable
// we simply keep whatever local state we already had.
fetch(`${API_BASE}/subscription/sync`, { method: 'POST' })
.then((r) => {
if (r.ok) dispatch(fetchSettings());
})
.catch(() => { /* offline — next launch will reconcile */ });
.catch(() => {});
}, [dispatch]);
// Refetch settings when the window regains focus. Catches every out-of-
// band settings mutation that doesn't come through a renderer-dispatched
// thunk: Stripe checkout's bearer-handoff page POSTing /api/subscription/
// activate, the new sign-in flow's bearer-handoff POSTing /api/auth/
// signin-activate, manual ~/.openswarm/settings.json edits, etc. Throttled
// by the browser's natural focus cadence (one refetch per Cmd-Tab back).
useEffect(() => {
const onFocus = () => { dispatch(fetchSettings()); };
window.addEventListener('focus', onFocus);
@@ -262,15 +228,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
return <>{children}</>;
};
// Sign-in gate. Sits between SettingsLoader and DefaultModelGuard so the
// gate is the very first thing a user without a user_id sees.
//
// In v2 the gate is **mandatory** — no skip link, no soft/hard split.
// The user must sign in (Google or email/password+verification code) before
// the rest of the app is interactive. Already-signed-in users skip the gate.
// Existing paid Stripe users without explicit user_id also skip — their
// bearer is valid even though user_id might not be backfilled yet.
/** Mandatory sign-in gate; first thing shown when settings lack a user_id or bearer. */
const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
@@ -278,10 +236,6 @@ const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children })
const alreadySignedIn = Boolean(settings.user_id || settings.openswarm_bearer_token);
// Poll settings every 2s while the gate is up so the moment the sign-in
// flow completes (browser POSTs /api/auth/signin-activate, local backend
// persists user_id to settings.json), we re-read settings and the gate
// auto-dismisses without the user clicking anything.
useEffect(() => {
if (!settingsLoaded || alreadySignedIn) return;
const id = setInterval(() => { dispatch(fetchSettings()); }, 2000);
@@ -301,10 +255,6 @@ const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children })
);
};
// Priority order for picking a default model when the user's stored
// default_model is unreachable (no matching provider connected). The user's
// preferred fallback ordering: direct provider keys first, then OpenSwarm
// Pro, then Copilot-powered OpenSwarm free tier.
const DEFAULT_MODEL_PRIORITY: string[] = [
'Anthropic',
'OpenAI',
@@ -313,9 +263,6 @@ const DEFAULT_MODEL_PRIORITY: string[] = [
'OpenSwarm',
];
// Preferred model pick inside each provider group. Ordered by the user's
// stated preference: Sonnet mid-tier for Claude, GPT-5.4 Mini for OpenAI,
// Flash for Gemini, and conservative picks for the shared tiers.
const DEFAULT_MODEL_PICKS: Record<string, string[]> = {
Anthropic: ['sonnet-cc', 'sonnet'],
OpenAI: ['gpt-5.4-mini', 'gpt-5.4'],
@@ -342,10 +289,7 @@ function pickFallbackModel(
return null;
}
// Reconciles the stored default_model against the set of models actually
// reachable given the user's current connections. When the stored value is
// unavailable, falls back per DEFAULT_MODEL_PRIORITY and shows a one-time
// warning so the user knows why their default changed.
/** Reconciles stored default_model against reachable models; falls back per DEFAULT_MODEL_PRIORITY and warns once. */
const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
@@ -393,7 +337,7 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
sx={{ fontSize: '0.8rem' }}
>
{warning && (
<>Default model <b>{warning.from}</b> is no longer available switched to <b>{warning.to}</b> ({warning.provider}).</>
<>Default model <b>{warning.from}</b> is no longer available, switched to <b>{warning.to}</b> ({warning.provider}).</>
)}
</Alert>
</Snackbar>
@@ -485,9 +429,7 @@ const ThemedApp: React.FC = () => {
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
{/* Dashboard renders persistently in AppShell so webviews survive nav. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
@@ -514,8 +456,7 @@ const ThemedApp: React.FC = () => {
);
};
// Tiny mount-point so the route-tracker hook can use useLocation() (which
// requires a Router ancestor). Lives inside HashRouter, runs once.
// useRouteTracker calls useLocation, must be inside HashRouter.
const RouteTrackerMount: React.FC = () => {
useRouteTracker();
return null;
+6 -90
View File
@@ -34,10 +34,6 @@ import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToo
import GlobalSearchPalette from '@/app/components/GlobalSearchPalette';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded';
interface SessionApprovalGroup {
@@ -61,17 +57,9 @@ const STATUS_CONFIG: Record<string, { label: string; tokenKey?: string }> = {
stopped: { label: 'Stopped', tokenKey: 'info' },
};
// ---------------------------------------------------------------------------
// Spring configs
// ---------------------------------------------------------------------------
const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 };
const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 };
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const StatusDot: React.FC<{ status: string; c: ReturnType<typeof useClaudeTokens> }> = ({ status, c }) => {
const cfg = STATUS_CONFIG[status];
const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost;
@@ -172,10 +160,6 @@ const AgentStatusRow: React.FC<{
);
};
// ---------------------------------------------------------------------------
// Compact activity indicator — subtle breathing dot
// ---------------------------------------------------------------------------
const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) => (
<Box
sx={{
@@ -193,20 +177,7 @@ const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = (
/>
);
// ---------------------------------------------------------------------------
// Memoized session projection
// ---------------------------------------------------------------------------
//
// DynamicIsland only reads name / status / dashboard_id / pending_approvals
// per session. We project to a stable shape so identity persists across
// streamingMessage deltas (which mutate state.streaming, not state.agents,
// but still trigger Immer to swap the agents root reference any time
// agentsSlice runs (fine in theory, but selector consumers re-fire).
//
// Per-session cache: when a session's relevant fields haven't moved,
// return the SAME inner object reference, so the outer dict can be
// dropped on shallowEqual if its key set + per-session refs match.
// Memoized session projection so identity persists across streamingMessage deltas; shallowEqual works.
type DiSession = {
id: string;
name: string;
@@ -245,8 +216,7 @@ const selectDynamicIslandSessions = createSelector(
out[sid] = next;
}
}
// Evict cache entries for sessions that disappeared. Without this,
// long sessions of dashboard switching slowly accumulate dead refs.
// Evict cache entries for vanished sessions or refs accumulate during dashboard switching.
for (const cached of _diSessionCache.keys()) {
if (!liveIds.has(cached)) _diSessionCache.delete(cached);
}
@@ -254,26 +224,13 @@ const selectDynamicIslandSessions = createSelector(
},
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DynamicIsland: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const islandRef = useRef<HTMLDivElement>(null);
// Read the whole sessions dict, but memoize its projection so the
// useSelector only emits a new value when one of the four fields we
// actually consume (name/status/dashboard_id/pending_approvals)
// changes for SOME session. createSelector caches both the inner
// per-session shape AND the outer dict, so re-runs return the same
// reference when nothing relevant moved, even though Immer flips
// the top-level dict ref on every streamed character elsewhere.
// shallowEqual: createSelector returns a fresh outer dict object on
// each re-run, but the inner refs are cached so when nothing relevant
// moved, key-by-key comparison short-circuits the re-render.
// Memoized projection + shallowEqual; only re-renders when one of the four fields actually changes.
const sessions = useAppSelector(selectDynamicIslandSessions, shallowEqual);
const history = useAppSelector((state) => state.agents.history, shallowEqual);
const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds, shallowEqual);
@@ -281,7 +238,7 @@ const DynamicIsland: React.FC = () => {
const [userExpanded, setUserExpanded] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// Global Cmd/Ctrl+K open search palette from anywhere.
// Global Cmd/Ctrl+K opens search palette from anywhere.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
@@ -293,24 +250,13 @@ const DynamicIsland: React.FC = () => {
return () => window.removeEventListener('keydown', handler);
}, []);
// Global Cmd/Ctrl+L clear the chat (Claude Code convention). Resolves
// the target session in priority order:
// 1) the session whose chat input currently has focus (when typing inside
// a contentEditable card body, the data-session-id climbs the DOM)
// 2) state.agents.activeSessionId (last touched chat)
// 3) a single visible session if there's exactly one
// No-op if none of those resolve. Hits the same /clear endpoint as the
// /clear slash command and dispatches clearSessionMessages so the visible
// transcript matches the now-empty SDK context.
// Cmd/Ctrl+L: clear the chat (focused card > activeSessionId > sole session); same as /clear.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.shiftKey || e.altKey) return;
if (e.key.toLowerCase() !== 'l') return;
// Walk up from activeElement looking for an agent-card marker.
// Falls back to Redux's activeSessionId, then to the only session
// if it's unambiguous.
let target: string | null = null;
const ae = document.activeElement as HTMLElement | null;
if (ae) {
@@ -347,8 +293,6 @@ const DynamicIsland: React.FC = () => {
return () => window.removeEventListener('keydown', handler);
}, [dispatch]);
// ---- Derived data ----
const groups: SessionApprovalGroup[] = useMemo(() => {
const result: SessionApprovalGroup[] = [];
for (const [sessionId, session] of Object.entries(sessions)) {
@@ -429,8 +373,6 @@ const DynamicIsland: React.FC = () => {
);
}, [groups]);
// ---- Island state machine ----
const islandState: IslandState = useMemo(() => {
if (userExpanded && (hasAgents || hasApprovals)) return 'expanded';
if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded';
@@ -445,8 +387,6 @@ const DynamicIsland: React.FC = () => {
}
}, [hasAgents, hasApprovals]);
// ---- Click outside to collapse ----
useEffect(() => {
if (islandState !== 'expanded') return;
const handler = (e: MouseEvent) => {
@@ -458,8 +398,6 @@ const DynamicIsland: React.FC = () => {
return () => document.removeEventListener('mousedown', handler);
}, [islandState]);
// ---- Callbacks ----
const onApprove = useCallback(
(requestId: string, updatedInput?: Record<string, any>) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
@@ -524,8 +462,6 @@ const DynamicIsland: React.FC = () => {
}
}, [islandState]);
// ---- Styling — uses the same neutral palette as the rest of the UI ----
const islandWidth = islandState === 'idle'
? 200
: islandState === 'compact'
@@ -542,8 +478,6 @@ const DynamicIsland: React.FC = () => {
? c.shadow.sm
: c.shadow.md;
// ---- Compact summary text ----
const compactText = useMemo(() => {
const parts: string[] = [];
if (activeAgents.length > 0) {
@@ -562,8 +496,6 @@ const DynamicIsland: React.FC = () => {
}
`, [c.status.warning]);
// ---- Render ----
return (
<>
{islandState === 'compact-actionable' && <style>{glowKeyframes}</style>}
@@ -655,10 +587,6 @@ const DynamicIsland: React.FC = () => {
);
};
// ---------------------------------------------------------------------------
// Idle pill — clickable search bar (opens GlobalSearchPalette).
// ---------------------------------------------------------------------------
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const SEARCH_HOTKEY = isMac ? '⌘K' : 'Ctrl+K';
@@ -713,10 +641,6 @@ const IdlePill: React.FC<{ c: ReturnType<typeof useClaudeTokens>; onClick: () =>
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact pill
// ---------------------------------------------------------------------------
const CompactPill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
text: string;
@@ -769,10 +693,6 @@ const CompactPill: React.FC<{
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact-actionable pill — single approval with icon + name + approve/deny
// ---------------------------------------------------------------------------
const CompactActionablePill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
request: ApprovalRequest;
@@ -854,7 +774,7 @@ const CompactActionablePill: React.FC<{
+{remainingCount - 1}
</Typography>
)}
<Tooltip title={isIntervention ? 'Done continue' : 'Approve'} arrow>
<Tooltip title={isIntervention ? 'Done, continue' : 'Approve'} arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onApprove(request.id); }}
@@ -926,10 +846,6 @@ const CompactActionablePill: React.FC<{
);
};
// ---------------------------------------------------------------------------
// Expanded card
// ---------------------------------------------------------------------------
const ExpandedCard: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
groups: SessionApprovalGroup[];
@@ -71,10 +71,7 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
if (existing.some((e) => e.id === el.id)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
// Same onboarding-bus emit as addElementForOwner. Drag-select goes
// through THIS path (via useDomElementSelector → ctx.addSelectedElement),
// not addElementForOwner — so without this branch, step 5 / 6's
// wait-for-attached event never fires when the user actually drags.
// Drag-select also emits agent:attached_to_browser; addElementForOwner alone misses this path.
if (el.semanticType === 'browser-card' || el.semanticType === 'agent-card') {
onboardingBus.emit('agent:attached_to_browser');
}
@@ -115,11 +112,7 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
if (existing.some((e) => e.semanticData?.selectId === el.semanticData?.selectId)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
// Surface the attachment to the onboarding bus. Step 5 ("have an
// agent use the browser") and step 6 ("have an agent control other
// agents") both wait on this event after the user repeats the
// drag-select gesture. Both element kinds (browser-card / agent-card)
// resolve the same wait — the runtime doesn't differentiate.
// Onboarding steps 5/6 wait on agent:attached_to_browser; both kinds resolve the same wait.
if (
el.semanticType === 'browser-card' ||
el.semanticType === 'agent-card'
+5 -13
View File
@@ -2,11 +2,11 @@ import React from 'react';
import { report, getRecentActions } from '@/shared/serviceClient';
interface Props {
/** Friendly title for the fallback card. Default: "Something broke." */
/** Title for the fallback card. */
title?: string;
/** Optional reset hook — if provided, the Reload button calls this instead of reloading the window. */
/** If provided, Reload calls this instead of reloading the window. */
onReset?: () => void;
/** Where the boundary lives, for support ("root" | "page:tools" | etc.). */
/** Where the boundary lives, for support ("root", "page:tools", etc.). */
scope?: string;
children: React.ReactNode;
}
@@ -15,11 +15,7 @@ interface State {
error: Error | null;
}
/**
* Catches uncaught render errors so a single broken component doesn't
* black out the whole app. Stack stays visible so users can copy/paste
* it to support; the cloud gets a fire-and-forget operational report.
*/
/** Catches uncaught render errors; fallback shows stack, cloud gets a fire-and-forget report. */
class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
@@ -34,12 +30,9 @@ class ErrorBoundary extends React.Component<Props, State> {
message: String(error?.message || error).slice(0, 500),
stack: String(error?.stack || '').slice(0, 2000),
component_stack: String(info?.componentStack || '').slice(0, 2000),
// Last 10 user-surface actions before the boundary tripped, so the
// backend can correlate the crash with what the user just did.
recent_actions: getRecentActions(10),
});
} catch {}
// surface in dev so developers can read the stack
if (typeof console !== 'undefined' && console.error) {
console.error('[ErrorBoundary]', error, info);
}
@@ -55,7 +48,6 @@ class ErrorBoundary extends React.Component<Props, State> {
};
handleResetState = () => {
// best-effort: clear any localStorage we own + reload
try {
const keys = Object.keys(localStorage);
for (const k of keys) {
@@ -127,7 +119,7 @@ class ErrorBoundary extends React.Component<Props, State> {
<div style={card}>
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600 }}>{title}</h2>
<p style={{ margin: '0 0 16px', color: '#9c9a92', fontSize: 14, lineHeight: 1.5 }}>
We caught it before it crashed everything. The error is below copy it
We caught it before it crashed everything. The error is below; copy it
if you want to share. Reload usually fixes it.
</p>
<div>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
/** Cute slime with × eyes and a red error badge error / warning illustration. */
/** Slime illustration with X eyes and red badge for errors/warnings. */
export const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
<path
@@ -50,7 +50,6 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
const searchLoading = useAppSelector((s) => s.agents.historySearch.loading);
const searchQuery = useAppSelector((s) => s.agents.historySearch.query);
// Debounced session/history search.
useEffect(() => {
if (!open) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -62,7 +61,6 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
};
}, [query, open, dispatch]);
// Reset on open + autofocus.
useEffect(() => {
if (open) {
setQuery('');
@@ -75,9 +73,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
setSelectedIndex(0);
}, [query]);
// Build results: dashboards first, then sessions. Sessions come from
// `historySearch.results` (closed) plus active in-memory sessions
// (not in history yet).
// Dashboards then sessions; merges in-memory active sessions with historySearch.results.
const results = useMemo<Result[]>(() => {
const q = query.trim().toLowerCase();
const dashboardResults: DashboardResult[] = Object.values(dashboards)
@@ -86,9 +82,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
.slice(0, 5)
.map((d) => ({ kind: 'dashboard', id: d.id, name: d.name }));
// Merge active in-memory sessions with history search results, dedupe by id.
const sessionMap = new Map<string, SessionResult>();
// Active in-memory sessions
for (const s of Object.values(sessions)) {
if (q && !(s.name || '').toLowerCase().includes(q)) continue;
sessionMap.set(s.id, {
@@ -100,8 +94,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
closedAt: null,
});
}
// When the query is empty, fall back to recent history rather than the
// (potentially huge) history dump — matches what the user sees on init.
// Empty query falls back to recent history, not the full dump.
const historyPool: HistorySession[] = q ? searchResults : Object.values(history).slice(0, 20);
for (const h of historyPool) {
if (sessionMap.has(h.id)) continue;
@@ -123,13 +116,10 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
if (r.kind === 'dashboard') {
navigate(`/dashboard/${r.id}`);
} else {
// Session: navigate to its dashboard (if any), focus the card.
// For closed sessions, resume first so the card can render.
if (r.dashboardId) {
navigate(`/dashboard/${r.dashboardId}`);
if (r.closedAt) {
// Closed history session — resume so it lands back in `sessions`
// and the dashboard layout can place a card for it.
// Closed history: resume so it lands in `sessions` and layout can place a card.
dispatch(resumeSession({ sessionId: r.id })).then(() => {
dispatch(setPendingFocusAgentId(r.id));
});
@@ -137,9 +127,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
dispatch(setPendingFocusAgentId(r.id));
}
} else if (r.closedAt) {
// No dashboard — just resume; the resumed session will land in some
// dashboard if it had one, otherwise it'll be orphan and we can't
// really "navigate" anywhere meaningful.
// Orphan closed session: resume; we can't navigate anywhere meaningful.
dispatch(resumeSession({ sessionId: r.id }));
}
}
@@ -164,11 +152,9 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
if (!open) return null;
// Group results visually. Sections collapse if empty.
const dashSection = results.filter((r): r is DashboardResult => r.kind === 'dashboard');
const sessSection = results.filter((r): r is SessionResult => r.kind === 'session');
// Map item index → flat results index for keyboard nav.
const flatIndexOf = (r: Result) => results.indexOf(r);
const isStillSearching = !!query.trim() && searchLoading && searchQuery !== query.trim();
+18 -94
View File
@@ -30,8 +30,7 @@ import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
// Settings is a global modal lazy-load so its 2.3K LOC + Stripe / OAuth helpers
// don't ship on first paint. Prefetched on idle so click-to-open feels instant.
// Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint.
const Settings = React.lazy(() => import('@/app/pages/Settings/Settings'));
import DynamicIsland from '@/app/components/DynamicIsland';
import Dashboard from '@/app/pages/Dashboard/Dashboard';
@@ -67,13 +66,7 @@ const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigateRaw = useNavigate();
// Wrap navigation in startTransition so React treats the route swap
// as non-urgent: the click handler returns immediately and paint
// happens before the heavy unmount-old-page / mount-new-page work
// runs. Eliminates the "click → wait → page appears" gap on slow
// routes (Actions, Apps, Skills) when the main thread is busy with
// agent streaming dispatches. Same call signature as useNavigate's
// return so existing call sites stay untouched.
// startTransition wrapper: route swap becomes non-urgent so click handler returns immediately; eliminates the "click, wait, page appears" gap on slow routes.
const navigate = useMemo(() => {
const fn = (...args: Parameters<typeof navigateRaw>) => {
startTransition(() => {
@@ -111,7 +104,6 @@ const AppShell: React.FC = () => {
});
const [snackbarDismissed, setSnackbarDismissed] = useState(false);
// ---- Warning banner: no internet / no model connected ----
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
@@ -125,19 +117,11 @@ const AppShell: React.FC = () => {
};
}, []);
// Derive "any model connected" from the /agents/models response (already
// fetched into Redux at app start via Main.tsx and re-fetched by
// Settings.tsx after every subscription connect/disconnect). That endpoint
// intersects BUILTIN_MODELS with both the user's API keys AND 9Router's
// live connection state, so a non-empty byProvider means there's at least
// one usable model — regardless of whether it came from a typed API key
// or an OAuth subscription flow. This replaces the previous approach of
// polling /agents/subscriptions/status in an effect keyed to anthropicKey,
// which didn't refresh when a non-Anthropic subscription was connected.
// /agents/models intersects BUILTIN_MODELS with API keys + 9Router state; non-empty means at least one usable model.
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const hasModelConnected = Object.keys(modelsByProvider).length > 0;
// Don't flash the banner while the initial /agents/models fetch is in flight
// Wait for initial fetch to land before flashing the banner.
const showWarningBanner = !isOnline || (modelsLoaded && !hasModelConnected);
const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
@@ -164,14 +148,7 @@ const AppShell: React.FC = () => {
(window as any).openswarm?.installUpdate();
}, [installing, dispatch]);
// Whole-dict subscriptions are deceptively expensive: `state.dashboards.items`
// and `state.outputs.items` are top-level dicts that get a NEW reference
// on any nested mutation (RTK/Immer behavior). With default referential
// equality, AppShell re-rendered on every dashboard rename, every output
// bump, every settings refresh that touched these slices, even though
// the dict CONTENTS were structurally identical from AppShell's POV.
// shallowEqual compares one level deep (key set + each value's identity),
// so AppShell now only re-renders on real structural changes.
// shallowEqual on top-level Immer dicts: nested mutations bump the dict reference, causing AppShell to re-render on every rename/output bump despite identical structure.
const dashboardItems = useAppSelector(
(state) => state.dashboards.items,
shallowEqual,
@@ -199,9 +176,7 @@ const AppShell: React.FC = () => {
dispatch(fetchOutputs());
}, [dispatch]);
// Idle-prefetch the lazy Settings chunk so click-to-open is instant.
// requestIdleCallback waits until the browser is genuinely idle so we
// don't fight first-paint work for the network slot.
// Idle-prefetch the lazy Settings chunk so click-to-open is instant; requestIdleCallback avoids fighting first-paint.
useEffect(() => {
const ric = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1500));
const handle = ric(() => {
@@ -286,9 +261,6 @@ const AppShell: React.FC = () => {
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
}, [sidebarWidth]);
// Native notification click handler. The notification helper fires a
// window event with the session id + dashboard id; bring the user back
// to that dashboard and queue a card focus.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail || {};
@@ -338,8 +310,6 @@ const AppShell: React.FC = () => {
? location.pathname.split('/dashboard/')[1]
: null;
// Sticky last-visited dashboard id — survives navigation away from /dashboard/:id
// so the Dashboard component can stay mounted with stable props.
const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
const activeAppId = location.pathname.startsWith('/apps/')
? location.pathname.split('/apps/')[1]
@@ -397,7 +367,6 @@ const AppShell: React.FC = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
{/* Draggable title bar */}
<Box
sx={{
height: 38,
@@ -418,10 +387,7 @@ const AppShell: React.FC = () => {
<IconButton
size="small"
onClick={() => setSidebarCollapsed((prev) => !prev)}
// Onboarding handle — the runtime reads aria-expanded to
// detect a collapsed sidebar and walks the user through
// clicking this toggle before targeting any sidebar-* item,
// mirroring the customization-collapse preflight.
// Onboarding runtime reads aria-expanded to detect a collapsed sidebar.
data-onboarding="sidebar-toggle"
aria-expanded={!sidebarCollapsed}
sx={{
@@ -499,7 +465,6 @@ const AppShell: React.FC = () => {
</Box>
</Box>
{/* Warning banner: no internet or no model connected */}
<Collapse in={showWarningBanner} timeout={350} unmountOnExit>
<Box
sx={{
@@ -521,10 +486,10 @@ const AppShell: React.FC = () => {
<ErrorSlime size={22} />
<Typography sx={{ fontSize: '0.78rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
{!isOnline
? 'No internet connection agents cannot reach AI models or external services'
? 'No internet connection; agents cannot reach AI models or external services'
: (
<>
No AI model connected {' '}
No AI model connected.{' '}
<Box
component="span"
onClick={() => dispatch(openSettingsModal('models'))}
@@ -653,16 +618,11 @@ const AppShell: React.FC = () => {
}}
>
<Box sx={{ flex: 1, overflow: 'auto', pt: 0.5, '&::-webkit-scrollbar': { width: 0 } }}>
{/* Dashboards section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleDashboardsClick}
data-onboarding="sidebar-dashboards"
// Expose expanded state so the onboarding runtime can
// skip the sidebar-click step when the section is already
// open (clicking it again would collapse it — opposite
// of what we want). Read via element.dataset.expanded /
// aria-expanded in the runtime guard.
// Onboarding reads expanded so it skips the click step (re-click would collapse).
data-expanded={dashboardsExpanded ? 'true' : 'false'}
aria-expanded={dashboardsExpanded}
sx={{
@@ -736,11 +696,7 @@ const AppShell: React.FC = () => {
return (
<Box
key={entry.id}
// Onboarding targets: every row carries a stable id so
// the AC can point at a specific dashboard, plus the
// first row gets a generic "first" alias so the AC
// can teach "click into a dashboard" without knowing
// any specific id.
// First row gets generic "first" alias so onboarding can teach "click into a dashboard" without a specific id.
data-onboarding={
idx === 0 ? 'dashboard-row-first' : `dashboard-row-${entry.id}`
}
@@ -815,10 +771,8 @@ const AppShell: React.FC = () => {
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Customization section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={() => {
@@ -867,12 +821,7 @@ const AppShell: React.FC = () => {
<Collapse in={customizationExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}` }}>
{CUSTOMIZATION_ITEMS.map((item) => {
// Replaced NavLink with a manual click handler so the
// wrapped (startTransition-aware) navigate runs.
// react-router's NavLink calls its own internal
// navigate which doesn't go through our wrapper,
// bypassing the transition optimization that makes
// Actions/Skills/Modes feel instant.
// Manual click handler instead of NavLink: NavLink's internal navigate bypasses our startTransition wrapper.
const isActive = location.pathname === item.path;
return (
<Box
@@ -880,9 +829,7 @@ const AppShell: React.FC = () => {
data-onboarding={item.onboarding}
onClick={() => navigate(item.path)}
onMouseEnter={() => {
// Hover-prefetch the lazy chunk so the click pays
// ~0ms instead of the multi-hundred-ms chunk parse.
// See Main.tsx for the path → import map.
// Hover-prefetch lazy chunk so click is ~0ms (see Main.tsx for path -> import map).
const fn = (window as any).__openswarmPrefetchRoute;
if (typeof fn === 'function') fn(item.path);
}}
@@ -895,12 +842,7 @@ const AppShell: React.FC = () => {
py: 0.5,
mx: 0.5,
cursor: 'pointer',
// Rounded pill for the active item, same shape as
// toolbar tabs. Use 25-percent accent alpha so
// the warm brand color reads CLEARLY against
// dark-mode bg.secondary; the earlier 10
// percent value muddied to grey and lost the
// selected affordance entirely.
// 25% accent alpha needed for readable contrast on dark-mode bg.secondary; 10% muddied to grey.
borderRadius: `${c.radius.md}px`,
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
@@ -928,10 +870,8 @@ const AppShell: React.FC = () => {
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Apps section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleAppsClick}
@@ -1020,12 +960,6 @@ const AppShell: React.FC = () => {
py: 0.5,
mx: 0.5,
cursor: 'pointer',
// Rounded pill for the active item, same shape as
// toolbar tabs. Use 25-percent accent alpha so
// the warm brand color reads CLEARLY against
// dark-mode bg.secondary; the earlier 10
// percent value muddied to grey and lost the
// selected affordance entirely.
borderRadius: `${c.radius.md}px`,
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
@@ -1055,7 +989,6 @@ const AppShell: React.FC = () => {
</Box>
{/* Settings */}
<Box
sx={{
px: 1,
@@ -1108,11 +1041,7 @@ const AppShell: React.FC = () => {
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
sx={{
// Hit-target is 6px for ergonomic drag but the handle is
// positioned at -3px so it overlaps the sidebar/content seam
// instead of occupying its own visible column. This kills the
// "chunky empty strip" that read as bad spacing without
// shrinking the actual drag region.
// 6px hit-target at -3px margin overlaps the seam so the drag region doesn't read as a visible empty strip.
width: 6,
marginLeft: '-3px',
marginRight: '-3px',
@@ -1143,8 +1072,7 @@ const AppShell: React.FC = () => {
)}
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page, position: 'relative' }}>
{/* Non-dashboard routes render here. Hidden when the dashboard view is active
so the persistent Dashboard layered above can take over the visible area. */}
{/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */}
<Box
sx={{
position: 'absolute',
@@ -1156,11 +1084,7 @@ const AppShell: React.FC = () => {
<Outlet />
</Box>
{/* Persistent Dashboard layer always mounted once a dashboard has been visited.
Hidden via CSS when on other routes so webviews and dashboard state survive
route navigation. The Dashboard component reads its dashboardId from the
sticky lastDashboardId hook so its dashboardId useEffect doesn't re-fire on
incidental URL changes. */}
{/* CSS-hidden on other routes so webviews + state survive nav. */}
{lastDashboardId && (
<DashboardHost visible={isDashboardViewActive}>
<Dashboard dashboardId={lastDashboardId} isActive={isDashboardViewActive} />
@@ -1242,7 +1166,7 @@ const AppShell: React.FC = () => {
}}
>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded restart to update`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded; restart to update`}
</Alert>
</Snackbar>
</Box>
@@ -6,24 +6,9 @@ interface DashboardHostProps {
children: React.ReactNode;
}
/**
* Wraps the Dashboard component in a stable container that toggles visibility
* via CSS instead of unmounting. This is what keeps the embedded webviews
* alive across non-dashboard route navigation.
*
* Why this approach (vs. display: none or unmount):
* - `visibility: hidden` preserves webview state without triggering Chromium
* to mark the page as hidden (so background sub-agents keep working).
* - `display: none` would trigger full layout recalc on toggle and may pause
* pages that check `document.hidden`.
* - Unmount destroys the webview DOM element, tearing down its Chromium tab.
*
* Also provides DashboardActiveContext to all children so they can gate
* expensive work (canvas rendering, screenshot capture, etc.) on visibility.
*/
/** Stable container that hides Dashboard via CSS so embedded webviews survive non-dashboard nav. */
const DashboardHost: React.FC<DashboardHostProps> = ({ visible, children }) => {
// When transitioning from visible -> hidden, blur any focused element so
// a focused webview doesn't keep stealing keyboard input behind the scenes.
// Blur focused element on hide so a focused webview can't keep stealing keyboard input.
useEffect(() => {
if (!visible) {
const el = document.activeElement;
@@ -38,10 +23,8 @@ const DashboardHost: React.FC<DashboardHostProps> = ({ visible, children }) => {
style={{
position: 'absolute',
inset: 0,
// Negative z-index when hidden so any visible Outlet content sits above
zIndex: visible ? 10 : -1,
visibility: visible ? 'visible' : 'hidden',
// Belt-and-suspenders: even if z-index ordering glitches, no clicks land
pointerEvents: visible ? 'auto' : 'none',
}}
>
+2 -16
View File
@@ -6,21 +6,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { DURATION_MS, EASE, pulseKeyframes } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Unified loading primitives. Three components, one aesthetic.
*
* <Skeleton variant="card|line|circle" width height />
* For full-component / full-page loads. Replaces decorative spinners.
*
* <InlineSpinner size />
* For inline button states + OAuth waits. Spinner = "I'm doing it now".
*
* <EmptyState icon title hint />
* For "nothing here yet" empty lists. Replaces ad-hoc "Loading..." text.
*
* `delayMs` (Skeleton + EmptyState): don't show until N ms have elapsed.
* Prevents the flash-of-skeleton on fast loads (<100ms common case).
*/
/** Loading primitives: Skeleton (block load), InlineSpinner (inline waits), EmptyState (no-items). */
interface SkeletonProps {
variant?: 'card' | 'line' | 'circle' | 'custom';
@@ -88,7 +74,7 @@ interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
hint?: string;
/** Show after N ms keeps "Loading..." flash off fast paths */
/** Show after N ms; keeps "Loading..." flash off fast paths. */
delayMs?: number;
}
@@ -1,15 +1,4 @@
// Singleton glue between the Onboarding panel UI and the AC runtime.
//
// Lifecycle:
// - OnboardingRoot mounts, calls Director.attach({ acRef, store, getAccentColor })
// - Panel "Show me" click → Director.startStep(stepId, sourceRect)
// - Director creates an AbortController, hands off to acRuntime.runStep
// - User dismisses panel mid-step → Director.cancelStep() → controller.abort()
//
// The runtime is the only place that touches the cursor handle directly.
// The Director is just a thin policy layer — it picks the spawn point,
// resolves dependencies, and translates Redux state into "should we walk
// step 4 again before step 5."
// Glue between the Onboarding panel and the AC runtime; thin policy layer over acRuntime.runStep.
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
@@ -24,9 +13,7 @@ interface AttachArgs {
acRef: RefObject<AgenticCursorHandle | null>;
store: Store<RootState>;
getAccentColor: () => string;
// Resolves whether a dependency's outcome is still satisfied. If true,
// the dependency's flow is skipped during walk_again. Step-5's depCheck,
// for example, asks "is there still a live browser card on the canvas?"
/** True if a dep is still satisfied; if so walk_again skips its flow. */
isDependencySatisfied: (depId: string) => boolean;
}
@@ -84,26 +71,9 @@ class OnboardingDirector {
const controller = new AbortController();
this.currentAbort = controller;
// Adaptive abort hooks — fire controller.abort() so the runtime's
// existing cleanup path takes over (cursor outros, popup retreats,
// panel re-shows for the user to re-attempt).
//
// 1. Lost target — tracker fires this when its cached element has
// been disconnected for >2.5s (user navigated away, collapsed
// the section, swapped a card out from under us).
// 2. Hash-route change — user clicked a sidebar entry / dashboard
// item / settings link mid-flow. Capture the route at start time
// and abort if it changes; lets the user explore freely without
// the AC stranding itself on the wrong page.
// Abort hooks: lost-target (cached element disconnected >2.5s) and hash-route change.
const startHash = window.location.hash;
// Console-visible breadcrumb for which abort listener fired. The
// existing `report()` calls only go to analytics; we couldn't tell
// whether step 8's recurring `AbortError: aborted` was from a
// lost-target (chat-input element disconnected by an in-flight
// remount) or from a route change (`hashchange` firing as a side
// effect of e.g. ViewEditor calling history.replaceState mid-flow).
// Logging on each abort path resolves that ambiguity without
// needing to open the Network/Analytics panel.
// Console breadcrumbs distinguish lost-target vs hashchange aborts without the Analytics panel.
const onLost = (e: Event) => {
const detail = (e as CustomEvent)?.detail;
// eslint-disable-next-line no-console
@@ -151,16 +121,11 @@ class OnboardingDirector {
}
}
// Step 6 previously triggered seed-orchestration-demo here to drop a
// stub "research" agent on the canvas. We removed it — step 6 now
// reuses the real chat the user created in step 3 as the "previous
// chat" the orchestrator bosses around, so no stub is needed.
}
export const onboardingDirector = new OnboardingDirector();
// Convenience: return the ordered roadmap (1..10) so callers don't import STEPS
// directly when they just need the schedule. STEPS itself is the source of truth.
/** Ordered roadmap (1..10); STEPS is the source of truth. */
export function getRoadmap(): OnboardingStep[] {
return STEPS;
}
@@ -1,13 +1,4 @@
// Docked top-right panel. Three visible states:
// - 'pill' — small "Finish setup X/N · Continue →" pill
// - 'expanded' — full card with title/desc/video preview/Show me + See all todos
// - 'roadmap' — full 10-step modal (delegated to OnboardingRoadmapModal)
// - 'hidden' — user-dismissed; only re-shows via Settings → Restart tour
//
// When a step completes, we render a one-time celebration overlay (check
// icon + strike-through over the title) for ~1500ms before crossfading to
// the next step's card. justCompletedStepId in Redux drives this; the
// useEffect below clears it on a timer.
/** Docked top-right panel; states: pill, expanded, roadmap, hidden. */
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -30,13 +21,9 @@ import { cursorStore } from './ac/cursorStore';
import OnboardingRoadmapModal from './OnboardingRoadmapModal';
const PANEL_WIDTH = 420;
// Long enough to register the strike-through + check, short enough that
// it doesn't feel like waiting before the next step appears.
const CELEBRATION_MS = 900;
// Tiny cursor-arrow SVG that mirrors the shape rendered by AgenticCursor
// so the AC visually appears to "come to life" out of this icon when the
// user clicks Show me.
/** Mirrors AgenticCursor's shape so AC visually "comes to life" out of this icon on Show me click. */
const CursorIconSmall: React.FC<{ size?: number; color: string }> = ({
size = 14,
color,
@@ -67,16 +54,11 @@ const OnboardingPanel: React.FC = () => {
const infoBtnRef = useRef<HTMLButtonElement | null>(null);
const [infoOpen, setInfoOpen] = useState(false);
// Cursor icon inside the "Show me" button — used to calculate the AC
// spawn point so the cursor visually flies out of this exact icon.
// AC spawn point flies out of this icon.
const cursorIconRef = useRef<HTMLSpanElement | null>(null);
// Cooldown for the Show me button so rapid double-clicks don't fire
// multiple parallel step starts (each one re-triggering backend
// seed/launch calls that already have an in-flight predecessor).
// Cooldown so rapid double-clicks don't fire parallel step starts; each one re-triggers in-flight backend seed/launch calls.
const lastShowMeClickRef = useRef<number>(0);
// Resolve current step. Prefer explicit currentStepId; fall back to
// first uncompleted step.
const currentStep = useMemo(() => {
const explicit = progress.currentStepId
? findStepById(progress.currentStepId)
@@ -85,8 +67,7 @@ const OnboardingPanel: React.FC = () => {
return STEPS.find((s) => !progress.completedSteps.includes(s.id)) ?? null;
}, [progress.currentStepId, progress.completedSteps]);
// Stage-relative progress counts. Spec mockup shows "Get started 1/6"
// (per-stage), not "1/10" (overall). The pill keeps overall.
// Stage-relative (panel) vs overall (pill).
const stageOf = currentStep?.stage ?? 'get_started';
const stageSteps = useMemo(
() => STEPS.filter((s) => s.stage === stageOf),
@@ -99,60 +80,33 @@ const OnboardingPanel: React.FC = () => {
const total = STEPS.length;
const done = progress.completedSteps.length;
// Celebration banner — strike-through + check on the just-completed
// step. Timer lives INSIDE CelebrationView so it can't be cancelled
// by parent OnboardingPanel re-renders or AnimatePresence remounts.
// Removed the parent-level useEffect that was here; it was vulnerable
// to a "rapid re-render → cleanup → new timer → repeat" loop where
// the celebration would never actually clear.
// Timer lives inside CelebrationView so parent re-renders can't cancel it.
const justDoneStepId = progress.justCompletedStepId;
const justDoneStep = justDoneStepId ? findStepById(justDoneStepId) : null;
const handleShowMe = async () => {
if (!currentStep) return;
// Click cooldown — without this, rapid double-clicks fire startStep
// twice. Each invocation calls cancelStep() then starts fresh, but
// any in-flight async ops (seed-orchestration-demo, agent launch,
// etc) keep running because cancelStep only aborts the controller,
// not pending backend fetches. Result: multiple stub agents
// created, multiple agents launched, panel state thrashing. 600ms
// is short enough not to feel laggy, long enough to absorb the
// user's "is it broken" reflex re-click.
// 600ms cooldown: cancelStep doesn't kill in-flight backend fetches so spam would launch parallel sessions.
const now = Date.now();
if (now - lastShowMeClickRef.current < 600) return;
lastShowMeClickRef.current = now;
// If running flag is stuck at true (a prior step's runStep ended
// without resetting it — possible after an unhandled error or HMR
// cycle), forcibly cancel and reset before starting fresh. This
// unsticks the "Show me does nothing" case without forcing the
// user to reload the app.
// Unstick a stale "running" flag from a prior unhandled error or HMR; yield a tick so reset lands first.
if (progress.running) {
onboardingDirector.cancelStep();
progress.setRunning(false);
// Yield a tick so the running=false dispatch lands before we
// start the new step (otherwise the runtime's first dispatch
// races with the reset).
await new Promise<void>((r) => window.setTimeout(r, 0));
}
const iconEl = cursorIconRef.current;
const rect = iconEl?.getBoundingClientRect();
// Sanity-check the rect: if the panel is mid-transition (Framer's
// exit animation hasn't completed), getBoundingClientRect can return
// (0,0,0,0) — which would land the cursor at the top-left corner
// (over the macOS traffic lights). Fall back to a sensible
// top-right anchor when the rect looks degenerate.
// Mid-transition rects can be 0,0,0,0; fall back to a top-right anchor.
const validRect =
rect && (rect.width > 0 || rect.height > 0) && (rect.left > 0 || rect.top > 0);
const spawnPoint = validRect
? { x: rect!.left + rect!.width / 2, y: rect!.top + rect!.height / 2 }
: { x: window.innerWidth - 80, y: 110 };
report('show_me_clicked', { step_id: currentStep.id });
// Watchdog: if AC fails to become visible within 2s of Show me
// (acRef.current was null after an HMR cycle, fadeIn silently
// rejected, etc), the panel stays hidden because nothing resets
// `running`. Check the cursorStore — if visible is still false,
// recover so the panel comes back instead of stranding the user.
// 2s watchdog recovers the panel if AC never becomes visible (HMR / silent rejection).
const watchedStepId = currentStep.id;
window.setTimeout(() => {
const acVisible = cursorStore.get().visible;
@@ -168,12 +122,7 @@ const OnboardingPanel: React.FC = () => {
if (!currentStep && !justDoneStep) return null;
if (progress.panelMode === 'hidden') return null;
// While AC is actively walking the user through a step, the panel
// would otherwise sit on top of targets in the top-right corner
// (Skills install button, "+ New app" on the Apps page, the Apps
// toolbar button, etc). Slide it off-screen with a small fade so the
// cursor has a clean canvas; it animates back when the step outros.
// motion.div handles both directions of the transition.
// Slide panel off-screen while AC runs so it doesn't sit on top of top-right targets (Skills install, "+ New app", etc).
const panelHidden = progress.running;
return (
@@ -187,11 +136,7 @@ const OnboardingPanel: React.FC = () => {
transition={{ type: 'spring', stiffness: 280, damping: 32 }}
sx={{
position: 'fixed',
// 38px title bar (drag region with traffic lights / OpenSwarm logo)
// + 6px breathing room. Sits just below the title bar — clear of
// the logo in the right corner but tighter to it than the
// previous 54px so the pill doesn't visually float away from
// the chrome.
// 38px title bar + 6px breathing room.
top: 44,
right: 16,
zIndex: 1200,
@@ -277,10 +222,6 @@ const OnboardingPanel: React.FC = () => {
overflow: 'hidden',
}}
>
{/* Header stage label + minimize + progress bar. No
bottom border anymore: the progress bar IS the
visual divider between header and body, no need for
a second separator line below it. */}
<Box
sx={{
px: 1.6,
@@ -347,8 +288,6 @@ const OnboardingPanel: React.FC = () => {
</Box>
</Box>
{/* Body celebration overlay or current step. AnimatePresence
crossfades between them so step transitions feel smooth. */}
<Box sx={{ position: 'relative' }}>
<AnimatePresence mode="wait" initial={false}>
{justDoneStep ? (
@@ -407,9 +346,7 @@ const OnboardingPanel: React.FC = () => {
</AnimatePresence>
</Box>
{/* Floating "?" info popover, anchored to the info icon. Renders
OUTSIDE the panel container so it can extend to the left without
clipping. */}
{/* Rendered outside the panel container so it can extend left without clipping. */}
{infoOpen && currentStep && (
<InfoPopover
stepId={currentStep.id}
@@ -445,10 +382,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
onToggleInfo,
running,
}) => {
// Click-to-zoom on the demo video. Lives at the card level so the
// overlay is portaled out (full viewport) regardless of how the panel
// is positioned. Auto-collapses on step change so a leftover overlay
// from step N doesn't linger into step N+1.
// Auto-collapses on step change so a leftover overlay from step N doesn't linger into step N+1.
const [videoExpanded, setVideoExpanded] = useState(false);
useEffect(() => {
setVideoExpanded(false);
@@ -516,10 +450,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
width: '100%',
height: '100%',
objectFit: 'cover',
// The source recordings have baked-in black side bars
// (recorded at a wider canvas than the OpenSwarm window
// actually filled). Scaling up + overflow:hidden on the
// parent crops them off the visible thumbnail area.
// Source recordings have baked-in black side bars; scale + parent overflow:hidden crops them off.
transform: 'scale(1.0)',
transformOrigin: 'center',
pointerEvents: 'none',
@@ -572,9 +503,6 @@ const StepCardBody: React.FC<StepCardProps> = ({
<ButtonBase
onClick={onOpenRoadmap}
sx={{
// mlAuto: pushes the help icon (next sibling) to the far
// right while keeping "See all todos" tucked next to Show
// me. Visual rhythm: [Show me] See all todos ............ ?
fontSize: 12.5,
fontWeight: 500,
color: c.text.secondary,
@@ -599,11 +527,6 @@ const StepCardBody: React.FC<StepCardProps> = ({
</IconButton>
</Box>
</Box>
{/* Click-zoom overlay portaled to body so it covers the full
viewport regardless of how the panel is positioned. Lives as a
sibling of the main card Box rather than as a child so the card
Box's children list stays a clean array of static elements
(helps React's children-validation in dev). */}
{videoExpanded && step.videoSrc
? createPortal(
<Box
@@ -681,18 +604,12 @@ interface CelebrationProps {
const CelebrationView: React.FC<CelebrationProps> = ({ step, accent }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
// Self-clearing timer: lives with the component instance and
// dispatches clearJustCompleted on mount. Because this component
// ONLY mounts when justCompletedStepId is set and unmounts when
// it's cleared, the timer fires exactly once per celebration.
// Cannot be cancelled by parent re-renders.
// Self-clearing timer fires once per celebration; cannot be cancelled by parent re-renders.
useEffect(() => {
const t = window.setTimeout(() => {
dispatch(clearJustCompleted());
}, CELEBRATION_MS);
return () => window.clearTimeout(t);
// Empty deps = fires once on mount, cleans up on unmount. The
// dispatch ref is stable per redux store.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@@ -804,8 +721,6 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
if (!r) return;
const POPOVER_W = 280;
const POPOVER_H = 240;
// Anchor below-and-to-the-left of the info button so the popover
// sits to the LEFT of the panel — matches figma image #66.
const top = Math.min(r.bottom + 8, window.innerHeight - POPOVER_H - 8);
const left = Math.max(8, r.right - POPOVER_W);
setPos({ top, left });
@@ -815,12 +730,10 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
return () => window.removeEventListener('resize', calc);
}, [anchorRef]);
// Click-away listener.
useEffect(() => {
const handler = (e: MouseEvent) => {
const t = e.target as Node;
if (anchorRef.current?.contains(t)) return;
// If click landed inside the popover, leave it open.
const pop = document.getElementById('onboarding-info-popover');
if (pop?.contains(t)) return;
onClose();
@@ -1,7 +1,4 @@
// Redux slice mirroring the persisted onboarding-v2 state. A thin
// subscriber in OnboardingRoot writes back to localStorage on change
// (debounced 200ms) so the in-memory state is the source of truth at
// runtime and disk is just for resume-after-restart.
// Mirrors persisted onboarding-v2 state; OnboardingRoot debounce-writes to localStorage on change.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@@ -13,8 +10,7 @@ export type PanelMode = 'pill' | 'expanded' | 'roadmap' | 'hidden';
export interface PerStepState {
lastViewedAt: number;
videoWatched?: boolean;
// For multi-choice steps: which option the user picked (used for branching
// and analytics).
/** Multi-choice answers per opId; drives branching and analytics. */
multiChoiceAnswers?: Record<string, string>;
}
@@ -26,25 +22,13 @@ export interface OnboardingProgressState {
panelMode: PanelMode;
dismissedAt: number | null;
perStepState: Record<string, PerStepState>;
// Runtime-only — not persisted. True while AC is actively executing a
// step's ops. The panel hides chrome and the user can't open the roadmap
// mid-flow without first cancelling.
/** Runtime-only; true while AC is executing a step's ops. */
running: boolean;
// Set on first launch detection so we don't re-init from defaults on
// every mount.
/** Set on first-launch detection so we don't re-init defaults on every mount. */
initialized: boolean;
// Set briefly when a step completes so the panel can render a one-time
// strike-through + celebration animation before transitioning to the
// next step. Cleared by clearJustCompleted (the panel calls this from
// a 1500ms timeout after the animation plays).
/** Brief celebration marker; clearJustCompleted clears it ~1.5s after the animation. */
justCompletedStepId: string | null;
// True after the user explicitly restarts the tour from Settings.
// Suppresses skipIf-based auto-marking for the rest of this tour run
// so the user gets a true fresh experience even if their prior data
// (existing skills, sessions, configured tools) would otherwise
// satisfy the predicates. False during normal first-launch detection
// so legitimately upgrading v1.0.29 users still see their already-
// configured pieces correctly pre-marked.
/** True after explicit restart-from-Settings; suppresses skipIf so the tour feels fresh. */
disableSkipIf: boolean;
}
@@ -86,9 +70,7 @@ const initialState: OnboardingProgressState = {
startedAt: 0,
completedSteps: [],
currentStepId: null,
// Default to expanded users land on the dashboard with the full
// step card visible so they see the next milestone + video preview
// without having to click into the pill first.
// Default expanded so users see next milestone + video preview on dashboard land.
panelMode: 'expanded',
dismissedAt: null,
perStepState: {},
@@ -123,7 +105,6 @@ const slice = createSlice({
state.disableSkipIf = Boolean(action.payload.disableSkipIf);
},
hydrate(state, action: PayloadAction<OnboardingProgressState>) {
// Replace from localStorage on launch.
Object.assign(state, action.payload, { running: false, initialized: true });
},
setPanelMode(state, action: PayloadAction<PanelMode>) {
@@ -145,8 +126,7 @@ const slice = createSlice({
markStepCompleted(state, action: PayloadAction<string>) {
if (!state.completedSteps.includes(action.payload)) {
state.completedSteps.push(action.payload);
// Trigger the celebration / strike-through animation. The panel
// listens for this and clears it ~1.5s later via clearJustCompleted.
// Triggers celebration anim; panel clears via clearJustCompleted after ~1.5s.
state.justCompletedStepId = action.payload;
}
},
@@ -176,11 +156,7 @@ const slice = createSlice({
state.perStepState = {};
state.running = false;
state.startedAt = Date.now();
// Tour was explicitly restarted — give the user a true fresh
// experience by suppressing skipIf for the rest of this run.
// Otherwise residual data (existing skills installed during a
// prior tour, leftover seed-orchestration-demo agents, etc)
// would auto-mark steps complete the moment Redux state ticks.
// Explicit restart: suppress skipIf so residual prior-tour data can't auto-mark.
state.disableSkipIf = true;
},
},
@@ -1,5 +1,4 @@
// Full 10-step roadmap. Modal opens from the panel's "See all todos" link.
// Stages cascade: Stage 2 unlocks once Stage 1 is fully complete.
/** 10-step roadmap modal opened from the panel's "See all todos"; Stage 2 unlocks once Stage 1 is fully complete. */
import React from 'react';
import { Modal, Box, Typography, IconButton, Button } from '@mui/material';
@@ -37,28 +36,18 @@ const OnboardingRoadmapModal: React.FC = () => {
progress.setPanelMode('expanded');
};
// Anchor the roadmap to the same top-right corner the panel sits in,
// so visually it reads as the panel "expanding into" the full roadmap
// rather than a centered modal that breaks spatial continuity. The
// origin point matches OnboardingPanel's top:44 / right:16 dock.
// Anchored top:44 / right:16 to match OnboardingPanel's dock so the modal reads as the panel expanding.
return (
<Modal
open={open}
onClose={close}
// Disable Modal's internal flex centering — we position the inner
// box absolutely from the top-right corner ourselves.
sx={{ inset: 0 }}
slotProps={{
backdrop: {
sx: { backgroundColor: 'rgba(0,0,0,0.42)' },
},
}}
// Modal mounts as soon as `open` is true; AnimatePresence inside
// owns the actual exit animation, so we keep keepMounted off and
// use AnimatePresence with mode="wait".
>
{/* Outer Box gets focus / aria attributes from MUI Modal. The
motion.div inside handles the slide-in. */}
<Box
sx={{
position: 'absolute',
@@ -79,8 +68,6 @@ const OnboardingRoadmapModal: React.FC = () => {
>
<Box
sx={{
// Roughly the same width as the expanded panel, just a touch
// wider so the 8-row roadmap breathes. 360 vs panel's 320.
width: 360,
maxHeight: 'calc(100vh - 80px)',
overflowY: 'auto',
@@ -93,7 +80,6 @@ const OnboardingRoadmapModal: React.FC = () => {
fontFamily: c.font.sans,
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
@@ -129,7 +115,6 @@ const OnboardingRoadmapModal: React.FC = () => {
</IconButton>
</Box>
{/* Stages */}
<Box sx={{ px: 2.4, pt: 1.6, pb: 0.5 }}>
{STAGE_GROUPS.map((group, gi) => {
const stageDone = group.steps.filter((s) =>
@@ -191,9 +176,7 @@ const OnboardingRoadmapModal: React.FC = () => {
key={step.id}
onClick={() => {
if (isLocked) return;
// If a step is mid-flow, abort it before
// jumping. Otherwise the AC keeps animating
// for a step the user no longer sees.
// Abort mid-flow step before jumping; otherwise AC keeps animating for a step the user no longer sees.
if (progress.running) {
onboardingDirector.cancelStep();
}
@@ -270,7 +253,6 @@ const OnboardingRoadmapModal: React.FC = () => {
})}
</Box>
{/* Footer */}
<Box
sx={{
px: 2.4,
@@ -1,5 +1,4 @@
// Top-level mount for the onboarding-v2 system. Hydrates persisted state,
// attaches the Director, mounts the Panel + AC.
// Top-level mount for onboarding-v2: hydrate state, attach Director, mount Panel + AC.
import React, { useEffect, useRef } from 'react';
import { useStore } from 'react-redux';
@@ -32,7 +31,6 @@ const OnboardingRoot: React.FC = () => {
const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
// Hydrate from localStorage on first mount, or initialize fresh state.
useEffect(() => {
if (progress.initialized) return;
if (!settingsLoaded) return;
@@ -43,15 +41,7 @@ const OnboardingRoot: React.FC = () => {
return;
}
// Always start with no pre-completed steps. The legitimate "v1.0.29
// user has a model already configured" case is now handled by the
// user simply walking through step 1 — the skipIf predicates still
// exist but they fire only via the live subscriber's baseline-aware
// path, which gates them behind real user action. Pre-marking at
// init time was unreliable: backend fetches land async, and at
// mount time we either don't have data yet (so nothing to mark)
// or we have it via stale Redux from a previous run (so we
// wrongly mark the wrong things). Net: simpler + always-fresh.
// Start with no pre-completed steps; live subscriber handles skipIf after baseline capture.
dispatch(
init({
currentStepId: STEPS[0]?.id ?? null,
@@ -61,19 +51,7 @@ const OnboardingRoot: React.FC = () => {
);
}, [progress.initialized, settingsLoaded, dispatch, store]);
// Watch for "user did the onboarding thing outside the flow" + bridge
// selected Redux signals to the event bus.
//
// Critical perf detail: the naive store.subscribe runs on EVERY dispatch
// (chat streaming = hundreds per second). The inner work — looping all
// STEPS, walking sessions, walking browserCards — is small individually
// but death-by-a-thousand-cuts over a long agent stream.
//
// Mitigation: collapse all dispatches in the same microtask into a
// single check via a `pending` flag + queueMicrotask. The state we
// care about (skipIf evaluations, card counts, session statuses) only
// matters at *commit* boundaries, never per-action — so coalescing
// dispatches is free.
// Bridge Redux signals to bus + auto-mark on skipIf. Coalesces microtask-bursts of dispatches.
useEffect(() => {
let last = new Set(progress.completedSteps);
let lastBrowserCount = Object.keys(
@@ -86,20 +64,7 @@ const OnboardingRoot: React.FC = () => {
(store.getState() as any).outputs?.items ?? {},
).length;
// Baseline-snapshot of which skipIf predicates were ALREADY satisfied
// at startup. Any step whose predicate is in this set won't be
// auto-marked by the live subscriber — the user has to actually go
// through it (or do the equivalent thing during this run). This kills
// the "step 3 instantly marks done because backend fetchSessions
// landed" bug, where async data arriving post-mount caused predicates
// to flip false→true and the subscriber marked steps without any
// user interaction.
//
// The snapshot is captured on the first store-tick AFTER a small
// settle delay — enough for fetchSettings/Sessions/Skills/Outputs
// to all land. Anything true at that point counts as "pre-existing
// backend state" and is excluded from auto-marking for the rest
// of the run.
// Snapshot pre-satisfied skipIf predicates after a 2s settle; those steps need real user action to mark.
let baselinePredicateMet: Set<string> | null = null;
const baselineCaptureAt = Date.now() + 2000;
let lastStatuses: Record<string, string> = {};
@@ -115,11 +80,7 @@ const OnboardingRoot: React.FC = () => {
seedStatuses();
let pending = false;
// Cached slice references — if these are referentially equal to what
// we saw last microtask, NOTHING we care about could have changed.
// Redux Toolkit's Immer produces new references only on slice writes,
// so identity comparison is sound and ~free. Drops the steady-state
// cost of this subscriber to a 5-pointer comparison per microtask.
// Slice-ref identity check; Immer mutates only on write so this 5-pointer compare is sound and free.
let prevAgents: unknown = null;
let prevDashboardLayout: unknown = null;
let prevOutputs: unknown = null;
@@ -129,11 +90,7 @@ const OnboardingRoot: React.FC = () => {
const runCheck = () => {
pending = false;
const state = store.getState();
// Reference-equality early-out. If none of the slices that drive
// any predicate, count, or status walk have changed reference,
// there's no work to do. Streaming chunks, agent message updates,
// settings polls all dispatch but most of them touch a single
// unrelated slice — so this skips ~95% of microtask wakeups.
// Early-out if no relevant slice reference moved; skips ~95% of microtask wakeups.
const sAgents = (state as any).agents;
const sLayout = state.dashboardLayout;
const sOutputs = (state as any).outputs;
@@ -153,8 +110,7 @@ const OnboardingRoot: React.FC = () => {
if (!anyChanged) return;
const suppressSkipIf = state.onboardingProgress?.disableSkipIf === true;
// Capture the baseline of pre-satisfied predicates after the
// initial fetch settle. This snapshot is sticky for the run.
// Capture pre-satisfied predicates after the fetch settle; sticky for the run.
if (baselinePredicateMet === null && Date.now() >= baselineCaptureAt) {
baselinePredicateMet = new Set();
for (const s of STEPS) {
@@ -165,11 +121,7 @@ const OnboardingRoot: React.FC = () => {
const allSkippablesDone = STEPS.every(
(s) => !s.skipIf || last.has(s.id),
);
// Skip the live evaluation entirely if (a) suppression is on,
// (b) baseline hasn't captured yet (we're still in the settle
// window — predicates would just see fetch-driven false→true
// flips that we want to ignore), or (c) every skippable step
// is already marked.
// Skip evaluation if suppressed, pre-baseline, or every skippable is already marked.
if (
!suppressSkipIf &&
!allSkippablesDone &&
@@ -178,11 +130,7 @@ const OnboardingRoot: React.FC = () => {
for (const s of STEPS) {
if (last.has(s.id)) continue;
if (!s.skipIf) continue;
// Predicates that were ALREADY true at baseline are excluded —
// the only way to mark them complete now is via genuine user
// action (bus events fired from product code) or via the
// tour's outro path. Prevents fetched-from-backend data from
// leaking past the gate later in the run.
// Baseline-met predicates require real user action (bus events or outro) to mark.
if (baselinePredicateMet.has(s.id)) continue;
if (s.skipIf(state)) {
last = new Set([...Array.from(last), s.id]);
@@ -228,18 +176,14 @@ const OnboardingRoot: React.FC = () => {
};
return store.subscribe(() => {
// Coalesce N dispatches in the same microtask into 1 check. Cheap
// boolean flag + queueMicrotask means the cost per dispatch is now
// a single property write, not a full state walk. The actual work
// still runs at most once per "tick" of state updates — which is
// all that matters for skipIf semantics.
// Coalesce N dispatches in the same microtask into 1 check.
if (pending) return;
pending = true;
queueMicrotask(runCheck);
});
}, [progress.completedSteps, dispatch, store]);
// Persist Redux progress localStorage, debounced.
// Persist Redux progress to localStorage, debounced.
useEffect(() => {
if (!progress.initialized) return;
const t = window.setTimeout(() => {
@@ -248,14 +192,13 @@ const OnboardingRoot: React.FC = () => {
return () => window.clearTimeout(t);
}, [progress, store]);
// Attach Director once the AC is mounted.
useEffect(() => {
onboardingDirector.attach({
acRef,
store,
getAccentColor: () => tokens.accent.primary,
isDependencySatisfied: (depId) => {
// Step 4's outcome is "a browser card currently exists on the canvas."
// Step 4: browser card currently on canvas.
if (depId === 'use_browser') {
const cards = store.getState().dashboardLayout?.browserCards ?? {};
return Object.keys(cards).length > 0;
@@ -266,9 +209,7 @@ const OnboardingRoot: React.FC = () => {
return () => onboardingDirector.detach();
}, [store, tokens.accent.primary]);
// Don't render the panel until we know whether the user is signed in. The
// panel sits on the dashboard, which only mounts post-sign-in anyway, but
// this guard keeps us out of the SignInGate's z-index space.
// Wait for sign-in state so we don't render under the SignInGate's z-index.
if (!settingsLoaded || !userId) return null;
if (!progress.initialized) return null;
@@ -1,6 +1,4 @@
// Visual gesture helpers — drop a transient DOM node, animate it, clean up.
// These don't trigger any product code; they just render eye-candy that
// makes the cursor's "intent" legible (a click ripple, a drag-rect).
// Transient visual gesture helpers: click ripple, drag-rect, glow.
export function clickRipple(x: number, y: number, color: string): void {
const SIZE = 28;
@@ -46,7 +44,7 @@ export function animateDragSelect(rect: DragRect, color: string, durationMs = 60
'width: 0px',
'height: 0px',
`border: 1.5px dashed ${color}`,
`background: ${color}1a`, // ~10% alpha
`background: ${color}1a`,
'pointer-events: none',
'z-index: 10499',
'border-radius: 4px',
@@ -69,9 +67,7 @@ export function animateDragSelect(rect: DragRect, color: string, durationMs = 60
});
}
// Soft glow rect overlaid on a target element. Used by highlight_section to
// draw the user's eye to a region (e.g. settings-pro-section) without
// taking a click. Caller is responsible for calling the returned cleanup.
/** Soft glow rect over a target (no click); caller must invoke the returned cleanup. */
export function spawnGlowRect(target: HTMLElement, color: string): () => void {
const rect = target.getBoundingClientRect();
const pad = 6;
@@ -100,7 +96,7 @@ export function spawnGlowRect(target: HTMLElement, color: string): () => void {
};
}
// Wait helper used between ops. Avoids `setTimeout` everywhere.
/** Promise-wrapped setTimeout for use between ops. */
export function sleep(ms: number): Promise<void> {
return new Promise((r) => window.setTimeout(r, ms));
}
@@ -11,43 +11,16 @@ interface Props {
}
const SAFE_PAD = 8;
// Slight bump to APPROX_W to match the larger font — keeps line-wrap
// behavior similar to before. The runtime measures the real rect via
// ref so this is just an initial-mount estimate.
const APPROX_W = 320;
const APPROX_H = 70;
// Distance from the bubble edge to the rounded corner radius — the
// tail's anchor x is clamped between TAIL_PAD and (w - TAIL_PAD) so
// the tail never juts past the corner.
const TAIL_PAD = 16;
// Pokémon-dialog cadence — letters pop in steadily, punctuation gets
// a small extra pause so sentences "land" instead of slurring together.
// Slowed 50% (was 20ms/char) so the popup reads at a more deliberate
// pace, matching the AC cursor's calmer motion.
const STREAM_MS_PER_CHAR = 30;
const STREAM_PUNCT_EXTRA_MS = 210; // after . , ! ? ; : (also +50%)
/** Extra pause after . , ! ? ; : */
const STREAM_PUNCT_EXTRA_MS = 210;
const STREAM_MIN_CHARS = 5;
/**
* Tiny popup that follows the cursor. Non-blocking no CTA.
*
* Streams text character-by-character like an RPG dialog box (modulo
* very short strings, which appear instantly to avoid visual jank on
* single-word popups).
*
* Positioning: vertical-only the bubble sits DIRECTLY ABOVE the
* cursor (centered horizontally on the cursor's actual x), with the
* tail pointing down at the target icon. Flips to BELOW the cursor
* only when there isn't room above. This places the popup "over" the
* thing it's referring to instead of beside it, so adjacent siblings
* (toolbar [+ grid globe history note], chat-input [cursor-circle clip
* mic], etc.) are never covered by the bubble's body.
*
* The tail anchors at the cursor's actual x relative to the bubble's
* (possibly clamped) left edge, so it still points at the icon even
* when the bubble is shifted by the viewport-edge clamp.
*/
/** Non-blocking cursor popup; streams char-by-char above the cursor (flips below if no room). */
const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
const c = useClaudeTokens();
const { x, y, visible } = useCursorPosition();
@@ -64,19 +37,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
flipY: true,
});
// Streaming text state — grows from 0 to text.length char-by-char.
// Use chained setTimeout (not setInterval) so we can vary the delay
// per character — punctuation gets an extra beat, mimicking the
// pacing of Pokémon-style dialog boxes where sentences "land."
//
// Diagnostic popups (anything containing the literal `[debug]`
// marker) skip streaming entirely. The recovery popup that fires on
// step failure carries a `[debug] <error message>` suffix so the
// user can see WHY a step bailed without opening DevTools — but at
// 30 ms/char + 210 ms per punctuation, the suffix takes the full
// 14 s popup duration to even start rendering, so by the time the
// user reads it the popup is already gone. Instant-render for these
// means the diagnostic appears immediately.
// [debug] popups skip streaming so the diagnostic suffix is visible immediately.
const isDebugPopup = text.includes('[debug]');
const skipStream = isDebugPopup || text.length < STREAM_MIN_CHARS;
const [streamCount, setStreamCount] = useState<number>(
@@ -97,9 +58,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
timer = null;
return;
}
// Look at the char we *just* revealed — if it's punctuation,
// wait an extra beat before the next one. Mirrors Pokémon's
// "..." and end-of-sentence pacing.
// Punctuation we just revealed gets an extra beat.
const justShown = text[i - 1];
const isPunct = /[.,!?;:]/.test(justShown);
const delay = STREAM_MS_PER_CHAR + (isPunct ? STREAM_PUNCT_EXTRA_MS : 0);
@@ -118,8 +77,6 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
const vw = window.innerWidth;
const vh = window.innerHeight;
// Default: bubble centered on cursor's x, sitting above the cursor.
// Flip below only when there isn't room above.
let nx = x - w / 2;
let ny = y - h - offset.y;
let flipY = true;
@@ -128,10 +85,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
flipY = false;
}
// Horizontal clamp — keep the bubble on-screen. The tail's anchor x
// is computed AFTER clamping so the tail always points at the
// cursor's actual position even when the bubble has been shoved
// inward by the viewport edge.
// Tail anchor x is computed AFTER clamp so it still points at the cursor when bubble shifts.
const nxClamped = Math.max(SAFE_PAD, Math.min(nx, vw - w - SAFE_PAD));
const nyClamped = Math.max(SAFE_PAD, Math.min(ny, vh - h - SAFE_PAD));
const tailRaw = x - nxClamped;
@@ -143,8 +97,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
if (!visible) return null;
const displayText = text.slice(0, streamCount);
// Reserve full width with invisible char to prevent the bubble from
// jiggling as letters arrive — invisible character keeps wrap consistent.
// Reserve full width with invisible chars so the bubble doesn't jiggle as letters arrive.
const isStreaming = streamCount < text.length;
return (
@@ -160,9 +113,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
}}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
// Slowed 50% from {0.14, stiffness 320, damping 32} — gives the
// bubble a more deliberate arrival, in sync with the cursor's
// gentler spring.
// Slowed 50% from {0.14, 320, 32}; matches cursor spring.
opacity: { duration: 0.21 },
scale: { duration: 0.21 },
x: { type: 'spring', stiffness: 160, damping: 22 },
@@ -191,10 +142,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
fontFamily: c.font.sans,
}}
>
{/* Tail pointing back at the cursor. Centered on the cursor's
actual x (via tailLeft) so the diamond's point lands on the
target icon, regardless of whether the bubble itself was
shifted by the viewport clamp. */}
{/* Tail anchored on cursor's actual x via tailLeft; lands on target despite bubble clamp. */}
<Box
sx={{
position: 'absolute',
@@ -206,11 +154,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
top: pos.flipY ? 'auto' : -5,
bottom: pos.flipY ? -5 : 'auto',
left: pos.tailLeft - 5,
// flipY=true bubble is above cursor, tail at bubble's
// bottom edge → bottom-right corner borders visible so the
// diamond points down at the cursor.
// flipY=false → bubble is below cursor, tail at top edge →
// top-left corner borders visible, diamond points up.
// flipY true: bubble above, tail at bottom (br corners visible, points down). flipY false flips.
borderRight: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderBottom: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderTop: pos.flipY ? 'none' : `1px solid ${c.accent.primary}`,
@@ -219,9 +163,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
/>
<Typography
sx={{
// Sized to feel like a Pokémon dialog — small but firm.
// 0.85rem reads cleanly without dominating the screen,
// and pairs with the bolder weight to stay legible.
// 0.85rem with bold weight reads cleanly without dominating.
fontSize: '0.85rem',
color: c.text.primary,
fontWeight: 600,
@@ -1,18 +1,6 @@
// Type a string into a target input or contentEditable element one character
// at a time, dispatching events that React's reconciler observes so the
// product's controlled input state stays in sync.
//
// React intercepts native value setters on <input>/<textarea> via a
// prototype-level descriptor, then dispatches 'input' events to its own
// synthetic event system. To make a fake change visible to React, we
// have to invoke the native setter via the prototype descriptor and then
// dispatch a real 'input' event. Setting `el.value = ...` directly is
// silently ignored by React's onChange.
// Type into input/textarea/contentEditable using React-prototype native setters so onChange fires.
// Version marker so we can verify the dev bundle actually reloaded after
// editing this file. Check `window.__OPENSWARM_TYPEINTO__` in DevTools
// — if it's missing or shows an older tag, Electron's renderer is
// running a cached bundle and needs a Cmd+R hard-reload.
// Bundle-version marker; check window.__OPENSWARM_TYPEINTO__ to confirm dev-reload landed.
if (typeof window !== 'undefined') {
(window as any).__OPENSWARM_TYPEINTO__ = 'v2-dom-direct-2026-05-12';
}
@@ -50,32 +38,10 @@ function dispatchInput(el: HTMLElement): void {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// contentEditable fields (the agent chat input is one) need a different
// path than <input>/<textarea>. Setting textContent nukes rich-content
// children (skill pills, etc), so we append a Text node at the end and
// dispatch a real InputEvent that React's reconciler treats as a
// keystroke. We used to call document.execCommand('insertText') here
// instead — that's the "idiomatic" way to programmatically type into a
// contentEditable — but in Electron with a webview loaded in the
// preview pane (App Builder step 8 / step 5 / step 6 all hit this),
// the webview steals document focus during its load. execCommand
// requires the host document to be focused AND the active element to
// be editable; without focus it silently no-ops while still returning
// true, so the wizard's `typeInto` "succeeded" but no characters ever
// landed, hasContent stayed false on the chat input, the send button
// never rendered, and step 8's `move_to chatSendButton` then burned
// its 15 s waitForSelector and threw into the recovery popup. The
// AC's "cursor" is purely visual — it never fires real focus events
// — so there's no way to get document focus back without the user
// clicking. DOM-level insertion + dispatched InputEvent works
// regardless of focus state.
// contentEditable: append a Text node + dispatch InputEvent; execCommand silently no-ops when a webview steals focus.
function insertContentEditableText(el: HTMLElement, ch: string): void {
el.focus();
// Append at the very end of the editable. Walk to the deepest
// last-text-node so we don't insert into the middle of a skill pill
// wrapper (those are inline-block element children with their own
// text). If the last child is an element (e.g., a <span> skill
// pill), we append a sibling text node after it.
// Append at the very end; walk past skill-pill spans by appending a sibling text node.
const range = document.createRange();
const last = el.lastChild;
if (last && last.nodeType === Node.TEXT_NODE) {
@@ -95,10 +61,7 @@ function insertContentEditableText(el: HTMLElement, ch: string): void {
sel.removeAllRanges();
sel.addRange(range);
}
// React's controlled-input bridge listens for `input` events. The
// `inputType: insertText` + `data: ch` mirrors what a real keystroke
// produces, so handleInput → updateHasContent fires and hasContent
// flips true → the send button finally renders.
// inputType:insertText + data:ch mirrors a real keystroke so React's handleInput fires.
el.dispatchEvent(
new InputEvent('input', {
bubbles: true,
@@ -111,8 +74,7 @@ function insertContentEditableText(el: HTMLElement, ch: string): void {
export interface TypeIntoOptions {
speedMs?: number;
// Optional callback fired after each character — lets the cursor
// re-align to the input's right edge as text grows.
/** Per-char callback so the cursor can re-align to the input's right edge as text grows. */
onTick?: () => void;
}
@@ -129,17 +91,11 @@ export async function typeInto(
text: string,
opts: TypeIntoOptions = {},
): Promise<void> {
// Default char-cadence — faster than the original 40ms (which felt
// like watching molasses for long URLs). 18ms is still slow enough to
// read live but doesn't make typing the main bottleneck of the step.
// 18ms default; readable without making typing the bottleneck.
const speed = opts.speedMs ?? 18;
el.focus();
// Per-character cadence is constant (no jitter — variable timing reads
// as glitchy, not natural). The one exception: insert a natural-reading
// pause after a comma / sentence-terminator / colon / semicolon so the
// streamed text breathes the way a human would. Anything else types at
// the constant `speed` value, beat by beat.
// Constant cadence (jitter reads glitchy); only punctuation gets a longer pause to breathe.
const punctPause = (ch: string): number => {
if (ch === ',') return 220;
if (ch === '.' || ch === '!' || ch === '?') return 320;
@@ -147,9 +103,6 @@ export async function typeInto(
return 0;
};
// Branch on element kind. contentEditable (the agent ChatInput uses
// a contentEditable div for skill-pill support) requires execCommand;
// <input>/<textarea> require the React-prototype-setter dance.
if (el.isContentEditable) {
for (const ch of text) {
insertContentEditableText(el, ch);
@@ -170,16 +123,7 @@ export async function typeInto(
}
}
// Post-type verification. Under heavy main-thread load (many agents
// streaming concurrently), execCommand('insertText') can silently
// no-op while React's reconciler is starved — AC "types" but the
// characters never land in the controlled input. Without this check,
// step 8 (App Builder) would "complete" with an empty draft and the
// user would see no app get built.
//
// After typing, give React up to 500ms to commit, then re-read the
// effective text. If it's missing most of what we typed, fall back
// to a single-shot insert that's much more reliable under load.
// Verify post-typing under load: if React's reconciler dropped chars, fall back to single-shot insert.
const target = text.trim();
if (!target) return;
for (let i = 0; i < 5; i++) {
@@ -188,8 +132,7 @@ export async function typeInto(
if (got.length >= Math.floor(target.length * 0.8)) return;
}
// Fallback: nuke contents and insert the full string in one shot.
// Loses the typing animation but preserves the user-visible outcome.
// Fallback: nuke contents and insert in one shot; loses animation, preserves outcome.
try {
if (el.isContentEditable) {
el.focus();
@@ -229,6 +172,6 @@ export async function typeInto(
dispatchInput(el);
}
} catch {
/* best-effort runtime's wait_user will time out and recover */
/* best-effort; runtime's wait_user will time out and recover */
}
}
@@ -29,38 +29,12 @@ export interface AgenticCursorHandle {
transition?: Record<string, unknown>,
) => Promise<void>;
pressClick: () => Promise<void>;
/**
* Lock the cursor to a live data-onboarding selector. After this is
* called the cursor re-resolves the selector and re-reads its rect on
* every animation frame, pinning itself (and any attached popup) to
* the element's current center. Survives reflows, scrolls, sidebar
* collapses, and React node swaps (uninstalled-card installed-card,
* etc.) the cursor follows the live target instead of stranding
* itself at the rect we read at the time of move_to.
*
* Pass an offset to override the default (center-of-rect). Calling
* startTracking again replaces any prior tracker; the next op that
* physically moves the cursor (move_to / click / type_into /
* drag_select / outro) calls stopTracking automatically.
*/
/** Pin cursor to a live selector; rAF re-resolves so it follows reflows + React node swaps. */
startTracking: (selector: string, offset?: { x: number; y: number }) => void;
stopTracking: () => void;
/**
* Show a non-blocking popup above the cursor. Returns immediately;
* the popup stays visible until hidePopup() is called or another
* showPopup replaces it. The runtime calls hidePopup() before any op
* that physically moves the cursor or types, so the popup naturally
* disappears when the cursor's "instruction" no longer applies.
*
* Placement is fixed: bubble centered on the cursor's x, sitting
* directly above the cursor (auto-flips below if no room above).
* See ACPopup for the full positioning logic.
*/
/** Non-blocking popup above cursor; auto-clears on next physical-move op. */
showPopup: (text: string) => void;
/**
* Single-select multi-choice. Resolves with the chosen option id; the
* panel that calls this can route the rest of the flow accordingly.
*/
/** Single-select multi-choice; resolves with the chosen option id. */
showMultiChoice: (q: string, opts: ACMultiChoiceOption[]) => Promise<string>;
hidePopup: () => void;
getPosition: () => { x: number; y: number };
@@ -76,11 +50,7 @@ interface MultiChoiceState {
resolve: (id: string) => void;
}
// Snappy spring — back to the tight 260/26 from before the 50%
// slowdown. The "calm" feel of the AC now comes from the popup's
// slower typewriter cadence + the 3s dwell floor; the cursor itself
// stays responsive so bubble-less moves (move_to → click, move_to →
// type_into, the canvas-controls tour) don't feel sluggish.
// Snappy 260/26 spring; calm comes from popup cadence + 3s dwell, not cursor delay.
const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 };
const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
@@ -91,19 +61,14 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const [popup, setPopup] = useState<PopupState | null>(null);
const [multiChoice, setMultiChoice] = useState<MultiChoiceState | null>(null);
// Active sticky-tracker handle. Set by startTracking, cleared by
// stopTracking. Survives renders via ref so the rAF loop can be
// cancelled cleanly even if the component re-renders mid-flight.
const trackerRef = useRef<{ stop: () => void } | null>(null);
// Mirror the cursor's logical position into the cursorStore so popups
// can follow without re-running through Framer's animation pipeline.
// Mirrored into cursorStore so popups follow without re-running through Framer's animation pipeline.
const writePos = (x: number, y: number, vis = true) => {
posRef.current = { x, y };
cursorStore.set({ x, y, visible: vis });
};
// Stop any sticky tracker. Idempotent.
const stopTrackingInternal = () => {
if (trackerRef.current) {
trackerRef.current.stop();
@@ -111,10 +76,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
}
};
// Defensive: if the AC unmounts mid-flow (Director.detach, panel
// hidden), the rAF callback would otherwise keep firing and pinning a
// dead component's `controls` to the live target every frame. The
// unmount cleanup cancels it.
// Unmount cleanup: without this the rAF callback keeps pinning a dead component's `controls` every frame after Director.detach.
useEffect(() => {
return () => stopTrackingInternal();
}, []);
@@ -132,10 +94,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
});
},
async moveTo(x, y, transition) {
// moveTo is for animated jumps to a fixed coord. Stop any prior
// tracker first so it doesn't keep snapping the cursor back to its
// old anchor mid-animation. The runtime calls startTracking after
// the await resolves, re-pinning to the live target.
// Stop prior tracker so it doesn't snap the cursor back to its old anchor mid-animation.
stopTrackingInternal();
await controls.start({
x,
@@ -166,39 +125,19 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const offY = offset?.y ?? 0;
let cancelled = false;
let rafId = 0;
// Cache the resolved node by reference. Re-querying every frame
// would make the cursor flicker between transient duplicate matches
// when React re-renders (e.g. Reddit Card hover state, Switch
// animation, install-toggle transition). Holding the node stable
// means the cursor follows the SAME element through reflows; we
// only re-query when the cached node leaves the document.
// Cache node by reference; re-querying every frame flickers between transient duplicate matches during React re-renders.
let cachedEl: HTMLElement | null = resolveSelector(selector);
let lastX = posRef.current.x;
let lastY = posRef.current.y;
// Lost-target tracking. If the cached element disconnects (user
// navigates away, collapses the section, etc) and we can't re-find
// it for >LOST_TIMEOUT_MS, fire the lost-target event so the
// runtime can outro gracefully and offer a recovery hint.
let lostSinceMs: number | null = null;
const LOST_TIMEOUT_MS = 2500;
const EPSILON = 0.5;
// Drop frames where the resolved rect would teleport the cursor by
// more than this. Real reflows move elements a few px per frame;
// 600px instantly is a sign of a stale/transient rect mid-commit.
// 600px+ rect jump in one frame = stale/transient mid-commit, not a real reflow.
const MAX_JUMP_PX = 600;
// Title-bar drag region (38px in AppShell). Pinning the cursor
// there lands it on the macOS traffic lights / Electron drag-area
// — never an intentional onboarding target. Skip those frames.
const TITLE_BAR_BOTTOM = 38;
// Throttle the rAF tracker to ~30fps. The browser fires rAF at the
// monitor refresh (60-144Hz typically), and re-querying rects +
// applying transforms every single frame is wasted work for what
// is fundamentally a "follow this rect" loop. 30fps still feels
// glued because the visible jitter threshold for static UI is
// higher than for animated UI. Halves rAF callback cost during
// pinned ops.
// ~30fps; per-frame rect reads are wasted for "follow this rect."
let lastTickAt = 0;
const TICK_INTERVAL_MS = 33; // ~30fps
const TICK_INTERVAL_MS = 33;
const tick = () => {
if (cancelled) return;
const now = performance.now();
@@ -211,17 +150,11 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
if (!cachedEl || !cachedEl.isConnected) {
cachedEl = resolveSelector(selector);
if (!cachedEl) {
// Element vanished. Start (or continue) the lost-target
// countdown — once we exceed the timeout, signal the
// runtime to abort.
const now = Date.now();
if (lostSinceMs === null) lostSinceMs = now;
if (now - lostSinceMs > LOST_TIMEOUT_MS) {
cancelled = true;
cancelAnimationFrame(rafId);
// Custom event the runtime listens for. Decoupled from
// controls/Promise machinery so we can fire from inside
// a rAF tick without races.
window.dispatchEvent(
new CustomEvent('openswarm:onboarding:lost_target', {
detail: { selector },
@@ -230,7 +163,6 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
return;
}
} else {
// Re-acquired — clear the countdown.
lostSinceMs = null;
}
} else {
@@ -242,11 +174,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
if (r.width > 0 || r.height > 0) {
const cx = r.left + r.width / 2 + offX;
const cy = r.top + r.height / 2 + offY;
// Viewport guards: skip frames where pinning would land the
// cursor outside the visible window OR inside the title-bar
// drag region. These don't help the user — they're symptoms
// of a stale read or a hidden/overflowed target — and the
// next legitimate frame will pin correctly.
// Off-window / title-bar frames are stale-reads or hidden targets.
const offWindow =
cx < 0 ||
cy < 0 ||
@@ -280,9 +208,6 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
stopTrackingInternal();
},
showPopup(text) {
// Non-blocking — replaces any existing popup. Caller advances the
// flow; popup auto-clears on the next op that physically moves the
// cursor (move_to / click / type_into / drag_select / outro).
setPopup({ text });
},
showMultiChoice(question, options) {
@@ -300,9 +225,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
hidePopup() {
setPopup(null);
if (multiChoice) {
// Defensive — multi_choice is supposed to resolve via user pick,
// but if the runtime aborts mid-question we don't want a dangling
// promise. Resolve with '' so callers can detect dismissal.
// Resolve with '' on abort so the promise doesn't dangle.
multiChoice.resolve('');
setMultiChoice(null);
}
@@ -316,15 +239,13 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
return createPortal(
<>
{/* Cursor body animated by Framer Motion. pointer-events:none so it
never blocks user interaction with the underlying app. */}
{/* pointer-events:none so the cursor never blocks underlying app interaction. */}
<motion.div
animate={controls}
onUpdate={(latest) => {
const x = typeof latest.x === 'number' ? latest.x : posRef.current.x;
const y = typeof latest.y === 'number' ? latest.y : posRef.current.y;
// Avoid React re-renders on every frame; just push to the external
// store so popups (which subscribe via useSyncExternalStore) follow.
// Push to external store instead of re-rendering; popups subscribe via useSyncExternalStore.
if (visible) cursorStore.set({ x, y });
}}
style={{
@@ -333,19 +254,12 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
left: 0,
zIndex: 10500,
pointerEvents: 'none',
// Translate origin: top-left of viewport. The animated x/y is the
// cursor tip's logical position.
transformOrigin: 'top left',
// Visual offset so the arrow's "tip" sits at (x,y) — the SVG below
// is drawn from its top-left, so shift it slightly up-and-left to
// align the pointer.
}}
>
{visible && (
<motion.div
animate={{
// Subtle idle pulse — closer to a soft heartbeat than a
// bouncing scale. Stays out of the way visually.
scale: [1, 1.04, 1],
}}
transition={{
@@ -355,9 +269,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
}}
style={{
transform: 'translate(-2px, -2px)',
// Two-layer glow: tight inner ring + softer outer halo.
// Tuned so the cursor reads clearly against light AND dark
// canvases without being distracting.
// Tight inner ring + soft outer halo reads on light AND dark canvases.
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
}}
>
@@ -366,9 +278,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
)}
</motion.div>
{/* Popups portaled separately so their pointer-events:auto isn't
inherited from the cursor wrapper's pointer-events:none. They
subscribe to cursorStore to track the live position. */}
{/* Portaled separately so cursor wrapper's pointer-events:none doesn't propagate. */}
<AnimatePresence>
{popup && <ACPopup key="popup" text={popup.text} />}
{multiChoice && (
@@ -388,7 +298,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
AgenticCursor.displayName = 'AgenticCursor';
export default AgenticCursor;
// Standard arrow cursor shape — 22x22, drawn pointing down-right.
/** 22x22 arrow cursor, points down-right. */
const CursorArrow: React.FC<{ color: string }> = ({ color }) => (
<svg
width="22"
@@ -1,11 +1,4 @@
// AC runtime — executes a step's ACOp[] sequence by calling into the
// AgenticCursor handle and the gesture/typing helpers. Runs ops sequentially
// with `await`; aborts cleanly when the AbortSignal fires (user dismisses
// panel mid-step, opens a different step, etc).
//
// Pure async. Not a class. Director (in OnboardingDirector.ts) is the
// caller — it owns the lifecycle (AbortController, AC ref, accent color
// resolution from the theme).
/** AC runtime: sequentially awaits a step's ACOp[] via the AgenticCursor handle; aborts on AbortSignal. */
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
@@ -18,7 +11,6 @@ import {
} from '../OnboardingProgressSlice';
import { report, markStepStarted, clearStepTiming } from '../telemetry';
import { onboardingBus, type OnboardingEvent } from '../eventBus';
// (gate bump done via onboardingBus.resetReplayGate at runStep entry)
import { waitForSelector, resolveSelector } from '../selectors';
import {
spawnGlowRect,
@@ -42,29 +34,14 @@ interface RunContext {
signal: AbortSignal;
silent: boolean; // suppress popups during dependency re-walks
stepId: string;
// Resolver function for finding a step by id (avoids circular import).
findStep: (id: string) => OnboardingStep | undefined;
// Cleanup for the highlight_section big glow.
highlightCleanup: { current: (() => void) | null };
// Wall-clock timestamp the current popup was shown at, or null if no
// popup is active. Used by ensurePopupDwell to guarantee every popup
// stays visible for at least MIN_POPUP_DWELL_MS before being replaced
// or cleared by the next auto-transition op.
popupShownAt: { current: number | null };
}
// Minimum time every popup stays visible before an auto-transition
// (move_to, click, type_into, drag_select, outro) or a popup replacement
// is allowed to clear it. user-driven transitions (wait_user resolving)
// also flow through here, but typically the user has already been
// reading for longer than this anyway. 6 s = streaming typewriter
// cadence + ~3 s post-stream read time, which was the user-asked floor
// for popups that don't require an explicit user action to advance.
// 6s = streaming typewriter cadence + ~3s post-stream read time; floor for popups that auto-transition without an explicit user action.
const MIN_POPUP_DWELL_MS = 6000;
// Resolves once `ms` has elapsed or the signal aborts (whichever
// comes first). Used inside ensurePopupDwell so a step cancel doesn't
// hang on a popup that just appeared.
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
if (ms <= 0) return Promise.resolve();
if (signal.aborted) return Promise.resolve();
@@ -81,8 +58,6 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
});
}
// Awaits the remaining minimum dwell time for the currently-displayed
// popup. No-op if no popup is active or the dwell has already elapsed.
async function ensurePopupDwell(ctx: RunContext): Promise<void> {
const shownAt = ctx.popupShownAt.current;
if (shownAt == null) return;
@@ -99,9 +74,6 @@ export interface RunStepArgs {
accentColor: string;
signal: AbortSignal;
findStep: (id: string) => OnboardingStep | undefined;
// Optional gate — if step.dependsOn[i] doesn't need re-walking (the
// dependency's outcome is still satisfied), the caller passes a function
// that returns true to skip it.
isDependencySatisfied?: (depId: string) => boolean;
}
@@ -111,10 +83,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
store.dispatch(setRunning(true));
store.dispatch(setCurrentStep(step.id));
markStepStarted();
// Bump the bus replay gate so any cached emits from prior steps (or
// the user's exploration in between) can't accidentally satisfy this
// step's wait_user gates. Subsequent once() subscriptions will only
// match emits that happen AFTER this bump.
// Bump bus replay gate so cached emits from prior steps can't satisfy this step's wait_user gates.
onboardingBus.resetReplayGate();
report('step_started', { step_id: step.id, stage: step.stage });
@@ -136,11 +105,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
try {
await ac.fadeIn(spawnPoint);
// Pre-flight: if the step needs a dashboard route and the user is on
// a different page (Settings closed but they're on /actions, /skills,
// etc), walk them into a dashboard first. Without this, the very
// first move_to of step 3/4/5/6/8 hits a missing target and the
// cursor stalls or strands itself over unrelated UI.
// Walk user into a dashboard first when step needs one; otherwise the first move_to hits a missing target on /actions, /skills, etc.
if (step.requiresDashboard && !isInDashboardRoute()) {
await runOps(buildOpenDashboardOps(), ctx);
}
@@ -152,18 +117,10 @@ export async function runStep(args: RunStepArgs): Promise<void> {
if (!depStep) continue;
if (dep.reopen === 'walk_again') {
report('dependency_walk', { step_id: step.id, dep_id: dep.stepId });
// Brief framing popup so the user knows why the cursor is
// about to walk them through a previous step's flow (e.g.
// step 5 asking step 4 to re-open a browser because they
// closed the one they spawned originally).
ac.showPopup('Quick setup before we continue.');
ctx.popupShownAt.current = performance.now();
await sleep(700);
// Non-silent walk: show popups so the user understands what
// each move_to is asking. Previously silent=true meant the
// cursor wandered through the dep's ops with no labels —
// robust but confusing. Telemetry isn't bumped for op-level
// events to avoid double-counting (silent kept for that).
// Non-silent dep-walk so each move_to has a label; telemetry stays per-step to avoid double-count.
await runOps(depStep.ops, { ...ctx, silent: false, stepId: depStep.id });
}
}
@@ -172,13 +129,6 @@ export async function runStep(args: RunStepArgs): Promise<void> {
await runOps(step.ops, ctx);
report('step_completed', { step_id: step.id });
store.dispatch(markStepCompleted(step.id));
// Belt-and-suspenders: dispatch clearJustCompleted from the runtime
// 950ms after the celebration starts. The OnboardingPanel ALSO has
// its own useEffect timer for this, but the runtime-side timer
// guarantees the celebration unsticks even if the panel's effect
// gets cancelled by a re-render race or AnimatePresence interaction
// — both dispatches go through the same idempotent reducer, so
// double-firing is harmless.
window.setTimeout(() => {
const cur = store.getState().onboardingProgress;
if (cur?.justCompletedStepId === step.id) {
@@ -200,10 +150,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
report('step_error', { step_id: step.id, error: msg });
}
// Re-show the panel IMMEDIATELY so the user sees it slide back in
// alongside the cursor's friendly retreat. Otherwise the panel
// stays hidden through the 1.8s recovery popup + fadeOut, which
// looks like the onboarding has crashed.
// Re-show panel immediately; otherwise it stays hidden through the 1.8s recovery popup + fadeOut, looking like a crash.
store.dispatch(setRunning(false));
try {
@@ -215,9 +162,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
}
const showMessage = !signal.reason || signal.reason !== 'user-cancel';
if (showMessage) {
// Diagnostic: surface a short version of the actual error in
// the recovery popup so we can see WHY the step bailed without
// needing DevTools open. 180-char cap keeps it readable.
// Surface short error in recovery popup; 180-char cap keeps it readable.
const isAbortErr =
(err as DOMException)?.name === 'AbortError' || signal.aborted;
const errSnippet = isAbortErr
@@ -226,11 +171,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
const debugSuffix = errSnippet
? `\n\n[debug] ${errSnippet}`
: '';
// Stash the full error on window so a dev can grab it from
// DevTools (`window.__OPENSWARM_LAST_ONBOARDING_ERR__`) even
// if the streaming popup hides the suffix. Full untruncated
// message + stack lives here, the 180-char snippet is just
// for the popup.
// Stash full untruncated error + stack on window for DevTools; popup only shows the 180-char snippet.
try {
(window as any).__OPENSWARM_LAST_ONBOARDING_ERR__ = {
step_id: step.id,
@@ -246,33 +187,20 @@ export async function runStep(args: RunStepArgs): Promise<void> {
err,
);
} catch {
/* defensive never let diagnostics throw */
/* defensive; never let diagnostics throw */
}
ac.showPopup(
"No worries, feel free to explore. Tap Show me whenever you're ready." +
debugSuffix,
);
// ACPopup streams text at ~30 ms/char + ~210 ms per punctuation
// mark, so a 240-char popup (base copy + 180-char debug
// suffix) takes ~10 s just to finish streaming. With a 5 s
// dwell the [debug] line never even appears on screen before
// the popup closes — which is why the user saw only the base
// recovery copy in every failure run. 14 s gives the streamer
// time to finish AND leaves a few seconds for the user to
// actually read the diagnostic line.
// 14s: ACPopup streams at ~30ms/char + ~210ms/punct, so a 240-char popup takes ~10s to finish streaming; needs time for streamer + read.
await new Promise<void>((r) => window.setTimeout(r, 14000));
}
} catch {
/* defensive never let cleanup throw */
/* defensive; never let cleanup throw */
}
// Retreat to the original spawnPoint — that's the icon's home
// position from before the panel hid itself, and after the
// setRunning(false) above the panel slides back to that exact spot.
// We previously re-read the live icon rect here, but that fires
// mid-slide-animation and yields transient coordinates (sometimes
// (0,0) if Framer hasn't applied the transform yet) — which is
// why the cursor was landing in the title-bar / kill-button area.
// Retreat to original spawnPoint; re-reading the live icon rect here yields transient coords mid-slide-animation (sometimes (0,0)).
try {
await ac.fadeOut(spawnPoint);
} catch {
@@ -294,9 +222,6 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
if (ctx.signal.aborted) {
throw new DOMException('aborted', 'AbortError');
}
// Op-level telemetry — gives drop-off granularity beyond
// step_started / step_completed. Skipped during silent dependency
// re-walks to avoid double-reporting.
if (!ctx.silent) {
report('op_started', {
step_id: ctx.stepId,
@@ -324,10 +249,6 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
duration_ms: Date.now() - opStart,
error: String(err),
});
// Console-visible breadcrumb so a dev with DevTools open can
// see WHICH op of WHICH step blew up without parsing telemetry.
// The catch in runStep above selectively logs based on error
// kind — this is more reliable and pinpoints the failing op.
// eslint-disable-next-line no-console
console.error(
`[onboarding] op failed: step=${ctx.stepId} op#${i}=${op.kind} ` +
@@ -343,13 +264,7 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const { ac, store, signal, accentColor } = ctx;
// Ops that physically move the cursor or change context implicitly
// clear any active popup, sticky tracker, AND active highlight glow —
// the previous instruction / pin / glow no longer applies once the
// cursor is heading somewhere new. wait_user / delay / popup /
// highlight_section / multi_choice keep all three visible (in
// particular, wait_user keeps tracking so the cursor stays glued to
// its target while we wait for the user's click).
// Physically-moving ops clear popup/tracker/glow; wait_user/delay/popup/highlight_section/multi_choice keep them.
const clearsTransients =
op.kind === 'move_to' ||
op.kind === 'click' ||
@@ -357,12 +272,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
op.kind === 'drag_select' ||
op.kind === 'outro';
if (clearsTransients) {
// Hold the previous popup on screen for MIN_POPUP_DWELL_MS before
// letting the next auto-transition clear it. Without this, a fast
// sequence like `popup → delay 350 → move_to → click` would yank
// the bubble before the user has a chance to read it. wait_user
// gates aren't routed through here because they don't transition
// until the user acts.
// Hold previous popup for MIN_POPUP_DWELL_MS before next auto-transition clears it; otherwise fast popup -> delay -> move_to sequences would yank the bubble before the user can read it.
await ensurePopupDwell(ctx);
ac.hidePopup();
ctx.popupShownAt.current = null;
@@ -375,57 +285,31 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
switch (op.kind) {
case 'move_to': {
// Pre-flight order matters: open the whole sidebar first (so
// sub-section markers exist in DOM), THEN check the Customization
// collapse, THEN target.
//
// Sidebar collapsed case ("AC freezes when user had sidebar
// hidden") — without this guard, waitForSelector for any
// sidebar-* target would hit its 2.5s lost-target timeout because
// the entire panel is unrendered.
// Order matters: open the whole sidebar first (sub-section markers must exist in DOM), THEN expand Customization, THEN target.
const expandSidebarOps = maybeBuildExpandSidebarOps(op.target);
if (expandSidebarOps) {
await runOps(expandSidebarOps, ctx);
}
// Customization collapsed case ("asks me to click on it twice")
// — without this guard, AC's popup pointed at an Actions/Skills/
// Modes item that wasn't yet visible, the user would click
// Customization to reveal it (which didn't satisfy the wait),
// then click the item, looking like a duplicate prompt.
const expandOps = maybeBuildExpandCustomizationOps(op.target);
if (expandOps) {
await runOps(expandOps, ctx);
}
const el = await waitForSelector(op.target);
const scrolled = scrollIntoViewIfNeeded(el);
// Cheaper rect-settle: instead of unconditionally sleeping 180ms
// after every scroll AND a possible 200ms retry, read the rect
// immediately and only wait if it actually looks bad. In the
// happy path (target already in view, layout stable), this skips
// both sleeps entirely.
const offX = op.offset?.x ?? 0;
const offY = op.offset?.y ?? 0;
const TITLE_BAR_BOTTOM = 38;
// "Truly broken" rect = zero size or pinned in title bar. NOT
// "below viewport" — that just means a smooth-scroll is still in
// progress. Treating below-viewport as degenerate caused step 2
// to abort with the recovery message every time the YouTube row
// was below the fold and AC had to scroll-then-pin.
// Broken = zero size or pinned in title bar; off-viewport just means smooth-scroll is mid-flight (don't treat as broken).
const isBroken = (rr: DOMRect, y: number): boolean =>
y < TITLE_BAR_BOTTOM ||
rr.width === 0 ||
rr.height === 0;
// Off-viewport but valid — element exists, scroll just hasn't
// landed it yet. Worth waiting through, not an abort condition.
const isOffViewport = (y: number): boolean =>
y > window.innerHeight || y < 0;
let r = el.getBoundingClientRect();
let cx = r.left + r.width / 2 + offX;
let cy = r.top + r.height / 2 + offY;
// Active poll for scroll-settle. Smooth-scrolls take 250-500ms;
// poll the rect every 60ms up to 1s. Bails the moment the element
// is in viewport with a non-broken rect, so the happy path stays
// fast (single poll, immediate exit).
// Poll scroll-settle every 60ms up to 1s (smooth-scrolls take 250-500ms); bail early when in viewport with non-broken rect.
const SCROLL_SETTLE_MAX_MS = 1000;
const POLL_MS = 60;
const startedAt = performance.now();
@@ -439,24 +323,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (!isBroken(r, cy) && !isOffViewport(cy)) break;
}
}
// Only abort if the rect is BROKEN after the settle window —
// off-viewport at this point means the scroll never landed,
// which usually means the page hasn't fully rendered yet, but
// pinning the cursor off-screen is harmless (user just sees
// nothing land for a moment).
if (isBroken(r, cy)) {
throw new Error(`waitForSelector: "${op.target}" rect did not settle`);
}
// Rect-stability check: when the user clicks "+" to open the
// dock chat, the chat input mounts then nudges into final
// position over a couple frames as siblings render. If we read
// the rect during that window and start the spring immediately,
// the cursor lands on a stale-target location and then the
// tracker has to drag it the remaining ~10-30px — visible as
// a "jump" right after the spring lands. Polling the rect for
// 2 stable consecutive frames (within 1.5px) guarantees we
// start the spring against the FINAL position. Capped at 200ms
// so we never block visibly. Most paths break out in 0-2 frames.
// Wait 2 stable frames before reading final rect; targets like the dock chat input nudge into position over a few frames after mount, and a stale-rect spring lands ~10-30px off and visibly jumps.
const STABILITY_MAX_MS = 200;
const STABILITY_THRESHOLD_PX = 1.5;
const stabilityStart = performance.now();
@@ -483,23 +353,13 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
prevCy = cy;
}
await ac.moveTo(cx, cy);
// One-frame yield before handing transform control to the
// sticky-tracker rAF. Without this, the tracker's first tick
// can fire while Framer's spring is still settling the final
// ~10px of the move, and the tracker's controls.set() cancels
// the spring mid-overshoot — visible as the cursor "teleporting"
// or disappearing into the destination. A single rAF lets the
// spring resolve before the tracker starts re-pinning every
// frame, which is when the cursor needs to start tracking
// anyway.
// rAF yield lets Framer's spring resolve before tracker's controls.set() cancels it mid-overshoot; otherwise cursor "teleports" into destination.
await new Promise<void>((r) => requestAnimationFrame(() => r()));
ac.startTracking(op.target, op.offset);
return;
}
case 'popup': {
if (ctx.silent) return;
// Replacing a popup-with-popup also has to honor the dwell floor,
// otherwise back-to-back popups would flash by too fast to read.
await ensurePopupDwell(ctx);
ac.showPopup(op.text);
ctx.popupShownAt.current = performance.now();
@@ -507,7 +367,6 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
case 'multi_choice': {
if (ctx.silent) return;
// Multi-choice supersedes any showing popup. Same dwell floor.
await ensurePopupDwell(ctx);
ctx.popupShownAt.current = null;
const id = await ac.showMultiChoice(op.question, op.options);
@@ -529,33 +388,21 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
case 'highlight_section': {
const el = await waitForSelector(op.target);
// Replace any previous highlight first so we don't stack glows.
if (ctx.highlightCleanup.current) {
ctx.highlightCleanup.current();
ctx.highlightCleanup.current = null;
}
const cleanup = spawnGlowRect(el, accentColor);
ctx.highlightCleanup.current = cleanup;
// Only show the popup if one was supplied — the runtime relies on
// the next op (typically wait_user) to keep the glow visible while
// the user reads. The glow is cleared by the next clearsTransients
// op (move_to / click / type_into / drag_select / outro) or at
// step-end in the runStep finally block.
if (op.popup && !ctx.silent) {
await ensurePopupDwell(ctx);
ac.showPopup(op.popup);
ctx.popupShownAt.current = performance.now();
}
// Optional minimum dwell so very-fast paths still register the
// glow visually. Defaults to a short beat; explicit durationMs
// overrides.
await sleep(op.durationMs ?? 600);
return;
}
case 'type_into': {
// Resolve text up-front — string-or-function. Function form lets a
// step pick its prompt at run-time based on current Redux state
// (e.g. step 3's YouTube vs. web-research fallback).
const resolvedText =
typeof op.text === 'function' ? op.text(ctx.store.getState()) : op.text;
const targetTrimmed = resolvedText.trim();
@@ -567,18 +414,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
return (e.textContent ?? '').trim();
};
// Type-and-verify is wrapped in a retry loop because the App
// Builder's chat input can be detached out from under us mid-
// stream: the workspace's `runtime/start → stop → start` cycle +
// ViewEditor's seed-then-navigate causes React to swap the
// AgentChat instance the user can see, leaving the element our
// `el` ref points at detached from the DOM. execCommand fires
// silently into the dead node, no text lands, hasContent stays
// false, and the send button never renders — which is what was
// pushing the wizard into the recovery popup. On a verify-miss
// we re-fetch the selector (which now resolves to the FRESH
// AgentChat's input) and type again. Two attempts is the max —
// a real "the input is genuinely broken" case shouldn't loop.
// Retry loop: App Builder's ViewEditor remounts can detach the chat input mid-type; re-fetch selector and retype.
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const el = await waitForSelector(op.target);
@@ -593,38 +429,28 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
ac.startTracking(op.target, { x: 0, y: 0 });
await typeInto(el, resolvedText, { speedMs: op.speedMs });
// Let React's onInput commit land before verifying. 80 ms is
// enough in the warm-path; we sleep longer between retries
// because a remount window is what we're racing.
// 80ms lets React's onInput commit land in the warm path.
await sleep(80);
if (!targetTrimmed) return;
// Re-fetch in case the original `el` was detached by a remount.
// resolveSelector will return whatever the CURRENT canonical
// chat-input is in the scope priority order.
// Re-fetch in case original `el` was detached by remount.
const currentEl = resolveSelector(op.target);
const verifyEl = currentEl ?? el;
const landed = readText(verifyEl);
if (landed.length >= Math.floor(targetTrimmed.length * 0.8)) {
// Success — text is in the live input.
return;
}
if (attempt < MAX_ATTEMPTS) {
// eslint-disable-next-line no-console
console.warn(
`[onboarding] type_into verify-miss for "${op.target}" attempt ${attempt}/${MAX_ATTEMPTS} typed=${landed.length}/${targetTrimmed.length}, retrying`,
`[onboarding] type_into verify-miss for "${op.target}" attempt ${attempt}/${MAX_ATTEMPTS}; typed=${landed.length}/${targetTrimmed.length}, retrying`,
);
// Wait long enough for any in-flight remount + reconcile to
// settle. 600 ms is longer than the ~500 ms stability window
// wait_for_dom uses, so by the time we retry the DOM is in
// its steady state.
// 600ms > the ~500ms stability window wait_for_dom uses, so DOM is in steady state by retry.
await sleep(600);
continue;
}
// Final attempt — same single-shot re-insert the old anti-
// revert guard used, against whatever element is current.
if (verifyEl.isContentEditable) {
verifyEl.focus();
const range = document.createRange();
@@ -646,19 +472,14 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
verifyEl.dispatchEvent(new Event('input', { bubbles: true }));
}
}
// One last verify after the fallback — if text STILL didn't land,
// throw with a descriptive error so the wizard's catch block
// shows a useful diagnostic instead of letting the next op
// (move_to chatSendButton) burn 15 s on a button that will
// never render because hasContent is false. The thrown message
// appears in DevTools console via the op-failed breadcrumb.
// Throw descriptive error so wizard's catch shows diagnostic instead of letting next op burn 15s on a button that never renders (hasContent=false).
await sleep(120);
const finalLanded = readText(resolveSelector(op.target) ?? verifyEl);
if (finalLanded.length < Math.floor(targetTrimmed.length * 0.5)) {
throw new Error(
`type_into: text never landed in "${op.target}" after ` +
`${MAX_ATTEMPTS} attempts (final length=${finalLanded.length}/${targetTrimmed.length}). ` +
`The chat input was probably detached by an in-flight remount ` +
`The chat input was probably detached by an in-flight remount; ` +
`check whether ViewEditor's seed-then-navigate is firing twice ` +
`or whether AgentChat's session key is swapping mid-stream.`,
);
@@ -678,15 +499,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
await ac.pressClick();
clickRipple(x, y, accentColor);
if (op.simulate !== false) {
// Disabled-button guard. If the resolved element (or any
// ancestor IconButton/Button wrapper) is in a disabled state
// when we go to fire the synthetic click, the click is a
// no-op AND we silently move on — which is the "AC clicks
// send and nothing happens" bug for step 6 (the contentEditable
// chat input sometimes reverts AC's typed text under load,
// leaving the send button disabled at click time). Detect it
// and try a brief recovery: wait one frame and re-check, in
// case the button just-now-enabled because state landed late.
// Disabled-button guard: synthetic click on disabled wrapper is silent no-op (step 6 "send does nothing"); wait one frame in case state lands late.
const isDisabled = (n: HTMLElement | null): boolean => {
while (n) {
if (n.hasAttribute('disabled')) return true;
@@ -704,17 +517,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
try {
el.click();
} catch {
/* swallow degrade to visual-only */
/* swallow; degrade to visual-only */
}
}
// Do NOT start tracking after a click. Many click targets are
// ephemeral — chat send buttons morph into stop buttons after
// submit, modal triggers unmount when the modal opens, etc.
// Tracking a disappearing element triggers lost-target → step
// abort, which kills the step before outro runs and prevents
// markStepCompleted from firing (the user is stuck on the same
// step forever). The cursor's last-set position from moveTo holds
// steady until the next op explicitly moves it.
// Do NOT startTracking after a click: many targets are ephemeral (send button -> stop button, modal trigger unmounts), and tracking a vanishing element trips lost-target -> step abort -> markStepCompleted never fires.
return;
}
case 'drag_select': {
@@ -722,13 +528,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
// Rect-stability poll. Without this, the dashed selection box is
// drawn at coordinates read mid-animation — e.g. when step 6
// clicks fit-to-view right before this op, the camera is still
// panning and the target's viewport rect changes frame-to-frame.
// Result: a box that's the wrong size or offset from the actual
// card. Wait for 2 stable consecutive frames (within 1.5px) up
// to 500ms before reading the final rect.
// Wait 2 stable frames before reading final rect; e.g. step 6's fit-to-view mid-pan changes target rect frame-to-frame and yields a misaligned selection box.
let r = el.getBoundingClientRect();
const stableStart = performance.now();
let prevLeft = r.left;
@@ -750,13 +550,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const toX = r.right + 12;
const toY = r.bottom + 12;
await ac.moveTo(fromX, fromY);
// Run the cursor and the dashed-rect animation in parallel, so the
// cursor visually leads the selection from top-left to bottom-right
// (matching how a real drag works) instead of stranding itself at
// the start corner while the box draws itself across the target.
// The cursor uses a 600ms tween with the same cubic-bezier the rect
// uses (ACGestures.ts) so the two motions stay in lock-step. Spring
// physics here would overshoot and desync from the CSS transition.
// Cursor + rect animate in parallel with matching cubic-bezier (ACGestures.ts) so they stay in lock-step; spring physics would overshoot and desync.
const RECT_DURATION_MS = 600;
await Promise.all([
animateDragSelect(
@@ -769,9 +563,6 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
ease: [0.4, 0, 0.2, 1],
}),
]);
// No tracking after drag_select — the visual ends at a calculated
// bottom-right corner, not the center of any element. Next op
// (typically wait_user or move_to) takes over positioning.
return;
}
case 'wait_user': {
@@ -781,19 +572,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
store,
op.timeoutMs,
);
// Retry-on-timeout for event_bus waits only: those fire on real
// user actions (browser:spawned, skill:installed, chat:message_sent,
// agent:attached_to_browser) — if the event never arrived the
// step's actual goal didn't happen, so silently marking the step
// done would let the user proceed against a half-broken state.
// One retry with a "didn't seem to go through" popup gives the
// user a clear chance to redo the action; if it times out a
// second time, we soft-succeed (same as before) so the step
// doesn't strand them forever.
//
// click_target + redux_predicate timeouts keep the original
// soft-success policy: the user might legitimately have done
// the underlying thing without our listener catching it.
// Retry on event_bus timeout only: those fire on real user actions, so silent soft-success would leave them in a half-broken state. click_target + redux_predicate keep soft-success (listener may have just missed).
if (first.timedOut && op.condition.kind === 'event_bus') {
report('wait_user_retry_prompted', {
step_id: ctx.stepId,
@@ -809,34 +588,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
);
}
ac.hidePopup();
// CRITICAL: stop tracking the previous move_to target now that
// the user has engaged with it. Many `wait_user click_target`
// targets are ephemeral — the App Builder's `+ New app` button
// disappears the instant the user clicks it (Views.tsx swaps
// ViewEditor in), and if the tracker keeps watching that now-
// disconnected element, the lost-target watchdog fires after
// 2.5 s and aborts the entire step (step 8 was aborting before
// it ever reached `type_into` for this exact reason — the
// `[onboarding] step make_app aborted: lost-target` console
// line pointed at `apps-new-button`, not at chat-input). The
// tracker for the NEXT target (chat-input, send button, etc.)
// starts in the next move_to / type_into op.
// CRITICAL: stop tracking previous target; many wait_user click_target's are ephemeral (App Builder's "+ New app" unmounts on click) and the 2.5s lost-target watchdog would abort the step before the next op runs.
ac.stopTracking();
// The user just did the thing — they don't need a dwell floor on
// top of having engaged with the popup. Clearing popupShownAt
// makes the next op's clearsTransients block a no-op for dwell,
// so the cursor starts moving toward the next target the instant
// the click registers. Without this, the cursor sat idle for up
// to MIN_POPUP_DWELL_MS while the next op's click listener was
// unregistered — so a quick follow-up click (e.g. clicking the
// chat-input select-mode toggle right after opening the chat)
// was being dropped on the floor, and the user saw "Show me"
// reset because the wait never resolved.
// Clear dwell: user already engaged with popup, so next op can move immediately. Without this, a quick follow-up click was dropped while the next listener was still being registered.
ctx.popupShownAt.current = null;
// Quick layout-settle — one frame is enough in 95% of cases
// (React commits on the next animation frame). The move_to
// op also has its own settle if the rect comes out degenerate,
// so this is just a cheap "let the click handler run" beat.
await sleep(16);
return;
}
@@ -855,19 +610,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
case 'wait_for_dom': {
const timeoutMs = op.timeoutMs ?? 8000;
const POLL_MS = 100;
// Stability gate: the matched element has to be the SAME node for
// STABILITY_POLLS consecutive polls (≈ 500 ms continuous presence)
// before we return success. Without this, step 8 was finding the
// App Builder's chat-input on poll N, returning, then the next
// op's typing ran straight into AgentChat's remount (the
// `runtime/start → stop → start` cycle from a draftLaunchMap swap
// + React Strict Mode double-effect) — the input became detached
// mid-stream, execCommand('insertText') silently no-op'd into the
// dead node, no text landed, hasContent stayed false, the send
// button was never rendered, and the wizard's next move_to
// chatSendButton burned its 15 s waitForSelector and threw into
// the recovery popup. Requiring stable identity walls off the
// remount window so we only proceed once the runtime has settled.
// Stability gate: same node identity for STABILITY_POLLS consecutive polls (~500ms) walls off AgentChat's runtime/start->stop->start remount; otherwise typing lands in a detached node and silently no-ops.
const STABILITY_POLLS = 5;
const startedAt = performance.now();
let stableEl: Element | null = null;
@@ -891,11 +634,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
await sleep(POLL_MS);
}
// Hard error on timeout, with DOM-state diagnostics so the dev
// console tells us WHY the selector didn't match — bare selector
// mismatch vs. the marker being on the right element but the
// wrong scope vs. nothing in DOM at all are three different bugs
// and we couldn't tell which from "step failed".
// Timeout error includes scope diagnostics: selector-mismatch vs. wrong-scope vs. nothing-in-DOM are three different bugs that "step failed" can't distinguish.
const scopeEls = Array.from(
document.querySelectorAll('[data-onboarding-scope]'),
).map((e) => (e as HTMLElement).getAttribute('data-onboarding-scope'));
@@ -925,11 +664,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
}
// Bring the target into view if any part of it is outside the viewport.
// Returns true if a scroll was actually triggered, false otherwise — the
// runtime uses this to decide whether to wait the smooth-scroll-settle
// beat. Scrolling-already-visible-element + 180ms wait would be pure
// added latency on every cursor move (~10s across the whole tour).
/** Returns true if a scroll was triggered; runtime uses this to skip the smooth-scroll-settle wait on already-visible targets (~10s saved across the tour). */
function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
@@ -943,45 +678,22 @@ function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
try {
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
} catch {
// Older webview / jsdom — fall back to instant scroll.
try {
el.scrollIntoView();
} catch {
/* nothing to do — tracker will still try to pin once visible */
/* tracker will still try to pin once visible */
}
}
return true;
}
// True when the current URL is `#/dashboard/<id>` (a specific dashboard,
// where the toolbar with + / browser / etc. mounts). False on `#/`
// (dashboard list), `#/skills`, etc. HashRouter only — production app
// uses HashRouter so window.location.hash is the source of truth.
//
// Note: path is singular `/dashboard/`, not `/dashboards/` — that mismatch
// previously had the runtime thinking the user was always in a dashboard
// (since neither shape ever matched), which is why "Show me" from the
// Actions/Skills pages would barrel into a missing-+ button.
// HashRouter path is singular `/dashboard/`, not `/dashboards/`; mismatch previously had runtime always-in-dashboard.
function isInDashboardRoute(): boolean {
const h = window.location.hash || '';
return /^#\/dashboard\/[^/?#]+/.test(h);
}
// Ops the runtime prepends when a step requires being inside a dashboard
// but the user isn't. State-aware: reads the live DOM to skip sub-steps
// the user has already satisfied, so we never force a click that would
// undo the desired state (e.g. clicking the Dashboards section header
// when it's already expanded — which would collapse it).
//
// The two sub-conditions:
// 1. Sidebar Dashboards section is expanded (so rows are visible).
// Marked via data-expanded="true" / aria-expanded="true" on the
// ListItemButton in AppShell.
// 2. The user has clicked into a dashboard (route #/dashboard/<id>).
//
// If (1) is already met, we skip the section-click. If (2) is met, we
// don't run any of these ops at all — the caller already gates on
// isInDashboardRoute().
// State-aware: skips section-click when Dashboards is already expanded so we don't collapse it.
function buildOpenDashboardOps(): ACOp[] {
const sectionEl = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-dashboards"]',
@@ -1014,27 +726,13 @@ function buildOpenDashboardOps(): ACOp[] {
return ops;
}
// Set of targets that live INSIDE the sidebar's Customization collapse.
// If a step's move_to points at one of these and the section is closed,
// the user can't see (or click) the target — they'd have to click
// Customization first to expand it. The runtime checks this before each
// move_to and, if needed, walks the user through the expand-click first.
// Same pattern as buildOpenDashboardOps: state-aware, no redundant clicks.
const CUSTOMIZATION_AREA_TARGETS = new Set<string>([
'sidebar-actions',
'sidebar-skills',
'sidebar-modes',
]);
// Targets that live anywhere inside the sidebar (top-level nav rows,
// section headers, items revealed by an expanded section). If a step's
// move_to points at one of these and the WHOLE sidebar is collapsed
// (the AppShell ViewSidebar toggle hides the entire panel), the target
// element isn't in the DOM at all and waitForSelector would freeze the
// AC for a full 2.5s lost-target timeout before giving up.
//
// `sidebar-toggle` is deliberately excluded — it lives in the top bar
// and is the thing we click to expand. Recursing on it would loop.
// `sidebar-toggle` excluded: it lives in the top bar (we click it to expand). Recursing would loop.
const SIDEBAR_AREA_TARGETS = new Set<string>([
'sidebar-settings-button',
'sidebar-dashboards',
@@ -1046,52 +744,22 @@ const SIDEBAR_AREA_TARGETS = new Set<string>([
'dashboard-row-first',
]);
/**
* If the requested target lives inside the sidebar panel and the panel
* is currently collapsed (aria-expanded="false" on the top-bar
* ViewSidebar toggle), return ops to walk the user through clicking the
* toggle. Otherwise return null. Caller should runOps() the result
* before its own move_to.
*
* This guard MUST run before maybeBuildExpandCustomizationOps because
* the Customization header itself lives inside the collapsible panel
* checking for an expanded Customization on a hidden panel would always
* read "not expanded" and queue an impossible click.
*/
/** MUST run before maybeBuildExpandCustomizationOps: Customization header is inside the collapsible panel, so expand-check on hidden panel queues an impossible click. */
function maybeBuildExpandSidebarOps(target: string): ACOp[] | null {
if (!SIDEBAR_AREA_TARGETS.has(target)) return null;
const toggle = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-toggle"]',
);
// aria-expanded reflects !sidebarCollapsed (true = sidebar visible).
// Missing / undefined means we couldn't find the toggle — assume
// visible and let waitForSelector handle the (unlikely) real failure
// so we don't gate on a missing marker.
// Missing toggle: assume visible and let waitForSelector handle the unlikely real failure.
const expanded =
toggle?.getAttribute('aria-expanded') === 'true' || toggle === null;
if (expanded) return null;
// Auto-expand: simulate-click the toggle. Previously we asked the
// user to click it themselves, which fell over in two ways: (1) if
// the AC's popup positioning glitched on collapsed-layout shift, the
// user saw the cursor freeze with no obvious instruction, and (2) the
// user shouldn't have to undo their own sidebar collapse to continue
// onboarding anyway. simulate:true fires the React onClick on the
// IconButton, the sidebar slides open, and the original move_to
// continues against the now-mounted target.
return [
{ kind: 'click', target: 'sidebar-toggle', simulate: true },
// Sidebar slide-in is ~200ms; the small delay lets the slide
// animation land before the next move_to reads rects.
{ kind: 'delay', ms: 260 },
];
}
/**
* If the requested target lives inside the Customization collapse and the
* section is currently closed, return ops to walk the user through
* expanding it. Otherwise return null. Caller should runOps() the result
* before its own move_to.
*/
function maybeBuildExpandCustomizationOps(target: string): ACOp[] | null {
if (!CUSTOMIZATION_AREA_TARGETS.has(target)) return null;
const header = document.querySelector<HTMLElement>(
@@ -1146,9 +814,6 @@ function waitForCondition(
if (timeoutMs && timeoutMs > 0) {
timer = window.setTimeout(() => {
// Surface the timeout to the caller so wait_user can decide
// whether to soft-succeed (the previous policy) or prompt the
// user to retry (the event_bus path — see wait_user handler).
finish(true);
}, timeoutMs);
}

Some files were not shown because too many files have changed in this diff Show More