diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 43894aa3..742d4aef 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -1,34 +1,6 @@ // Node-runtime patch loaded via `node --require ` 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); } diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index ffc355bb..0060711e 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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"\n{content}\n" ) 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"\n{chr(10).join(tree_lines)}\n" ) 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//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 = ( + "\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" + "" + ) 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): diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index bc0c0ea7..55869241 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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//` 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//. 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") diff --git a/backend/apps/agents/anthropic_proxy.py b/backend/apps/agents/anthropic_proxy.py index f4cd2a56..3fd93da9 100644 --- a/backend/apps/agents/anthropic_proxy.py +++ b/backend/apps/agents/anthropic_proxy.py @@ -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:/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=` 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 diff --git a/backend/apps/agents/browser_agent.py b/backend/apps/agents/browser_agent.py index 7c509075..728a07f6 100644 --- a/backend/apps/agents/browser_agent.py +++ b/backend/apps/agents/browser_agent.py @@ -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], 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
s — the accessibility tree sees roles and names " + "uses unlabeled
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
s with click handlers. " "Call BrowserListInteractives to get a numbered list (`[1]
\s*(?=]*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']*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)', block, flags=re.DOTALL, ) if not link_match: - # Try reversed attribute order link_match = re.search( r']*href="([^"]*)"[^>]*class="[^"]*result__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']*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)', 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(" 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 diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index 08888ec6..c069e33a 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -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} diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 6577f1dd..bc3ebed4 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -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", diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 56bc7fc6..c29c75c7 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -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 diff --git a/backend/apps/discord_mcp_shim/server.py b/backend/apps/discord_mcp_shim/server.py index 656d11e3..a806841f 100644 --- a/backend/apps/discord_mcp_shim/server.py +++ b/backend/apps/discord_mcp_shim/server.py @@ -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: diff --git a/backend/apps/health/health.py b/backend/apps/health/health.py index 1ffbddfd..c24fb463 100644 --- a/backend/apps/health/health.py +++ b/backend/apps/health/health.py @@ -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, diff --git a/backend/apps/modes/models.py b/backend/apps/modes/models.py index b639660a..09bb94c7 100644 --- a/backend/apps/modes/models.py +++ b/backend/apps/modes/models.py @@ -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." diff --git a/backend/apps/modes/modes.py b/backend/apps/modes/modes.py index ebf399ef..e5e77586 100644 --- a/backend/apps/modes/modes.py +++ b/backend/apps/modes/modes.py @@ -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: diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index 25991c4a..ee9128af 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -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 /backend/ - # So router is at /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 `, 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 5–15s 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 # /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-` and a user-defined -# `prefix`. At request time, model_id `/` 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-`, -# letting us address each provider as `cp-/` 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 / +# routes to that node's baseUrl. We mirror settings.custom_providers[] with +# prefix `cp-` 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""" Authorization Complete } @@ -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; onClick: () => ); -// --------------------------------------------------------------------------- -// Compact pill -// --------------------------------------------------------------------------- - const CompactPill: React.FC<{ c: ReturnType; text: string; @@ -769,10 +693,6 @@ const CompactPill: React.FC<{ ); -// --------------------------------------------------------------------------- -// Compact-actionable pill — single approval with icon + name + approve/deny -// --------------------------------------------------------------------------- - const CompactActionablePill: React.FC<{ c: ReturnType; request: ApprovalRequest; @@ -854,7 +774,7 @@ const CompactActionablePill: React.FC<{ +{remainingCount - 1} )} - + { e.stopPropagation(); onApprove(request.id); }} @@ -926,10 +846,6 @@ const CompactActionablePill: React.FC<{ ); }; -// --------------------------------------------------------------------------- -// Expanded card -// --------------------------------------------------------------------------- - const ExpandedCard: React.FC<{ c: ReturnType; groups: SessionApprovalGroup[]; diff --git a/frontend/src/app/components/ElementSelectionContext.tsx b/frontend/src/app/components/ElementSelectionContext.tsx index 7c0b369b..d3a77373 100644 --- a/frontend/src/app/components/ElementSelectionContext.tsx +++ b/frontend/src/app/components/ElementSelectionContext.tsx @@ -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' diff --git a/frontend/src/app/components/ErrorBoundary.tsx b/frontend/src/app/components/ErrorBoundary.tsx index 9fdd9120..9d36610d 100644 --- a/frontend/src/app/components/ErrorBoundary.tsx +++ b/frontend/src/app/components/ErrorBoundary.tsx @@ -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 { state: State = { error: null }; @@ -34,12 +30,9 @@ class ErrorBoundary extends React.Component { 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 { }; 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 {

{title}

- 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.

diff --git a/frontend/src/app/components/ErrorSlime.tsx b/frontend/src/app/components/ErrorSlime.tsx index 1205b4cc..36a92a40 100644 --- a/frontend/src/app/components/ErrorSlime.tsx +++ b/frontend/src/app/components/ErrorSlime.tsx @@ -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 }) => ( = ({ 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 = ({ open, onClose }) => { }; }, [query, open, dispatch]); - // Reset on open + autofocus. useEffect(() => { if (open) { setQuery(''); @@ -75,9 +73,7 @@ const GlobalSearchPalette: React.FC = ({ 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(() => { const q = query.trim().toLowerCase(); const dashboardResults: DashboardResult[] = Object.values(dashboards) @@ -86,9 +82,7 @@ const GlobalSearchPalette: React.FC = ({ 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(); - // 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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(); diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 87d53c5e..200478e7 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -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) => { 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 ( - {/* Draggable title bar */} { 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 = () => { - {/* Warning banner: no internet or no model connected */} { {!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.{' '} dispatch(openSettingsModal('models'))} @@ -653,16 +618,11 @@ const AppShell: React.FC = () => { }} > - {/* Dashboards section */} { return ( { - {/* Divider */} - {/* Customization section */} { @@ -867,12 +821,7 @@ const AppShell: React.FC = () => { {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 ( { 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 = () => { - {/* Divider */} - {/* Apps section */} { 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 = () => { - {/* Settings */} { 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 = () => { )} - {/* 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. */} { - {/* 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 && ( @@ -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`} diff --git a/frontend/src/app/components/Layout/DashboardHost.tsx b/frontend/src/app/components/Layout/DashboardHost.tsx index 5ce94e5b..19e97420 100644 --- a/frontend/src/app/components/Layout/DashboardHost.tsx +++ b/frontend/src/app/components/Layout/DashboardHost.tsx @@ -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 = ({ 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 = ({ 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', }} > diff --git a/frontend/src/app/components/Loading.tsx b/frontend/src/app/components/Loading.tsx index 49b92d1c..f36c8156 100644 --- a/frontend/src/app/components/Loading.tsx +++ b/frontend/src/app/components/Loading.tsx @@ -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. - * - * - * For full-component / full-page loads. Replaces decorative spinners. - * - * - * For inline button states + OAuth waits. Spinner = "I'm doing it now". - * - * - * 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; } diff --git a/frontend/src/app/components/Onboarding/OnboardingDirector.ts b/frontend/src/app/components/Onboarding/OnboardingDirector.ts index 24704eb5..6522adf3 100644 --- a/frontend/src/app/components/Onboarding/OnboardingDirector.ts +++ b/frontend/src/app/components/Onboarding/OnboardingDirector.ts @@ -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; store: Store; 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; } diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index 4d55d32c..245fe45a 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -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(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(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(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((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. */} { - {/* Body — celebration overlay or current step. AnimatePresence - crossfades between them so step transitions feel smooth. */} {justDoneStep ? ( @@ -407,9 +346,7 @@ const OnboardingPanel: React.FC = () => { - {/* 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 && ( = ({ 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 = ({ 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 = ({ = ({ - {/* 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( = ({ 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 = ({ 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 = ({ 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(); diff --git a/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts index e0d0d829..c9e959fb 100644 --- a/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts +++ b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts @@ -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; } @@ -26,25 +22,13 @@ export interface OnboardingProgressState { panelMode: PanelMode; dismissedAt: number | null; perStepState: Record; - // 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) { - // Replace from localStorage on launch. Object.assign(state, action.payload, { running: false, initialized: true }); }, setPanelMode(state, action: PayloadAction) { @@ -145,8 +126,7 @@ const slice = createSlice({ markStepCompleted(state, action: PayloadAction) { 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; }, }, diff --git a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx index 6289a359..0c13ae36 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx @@ -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 ( - {/* Outer Box gets focus / aria attributes from MUI Modal. The - motion.div inside handles the slide-in. */} { > { fontFamily: c.font.sans, }} > - {/* Header */} { - {/* Stages */} {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 = () => { })} - {/* Footer */} { 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 | null = null; const baselineCaptureAt = Date.now() + 2000; let lastStatuses: Record = {}; @@ -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; diff --git a/frontend/src/app/components/Onboarding/ac/ACGestures.ts b/frontend/src/app/components/Onboarding/ac/ACGestures.ts index 4b604f6e..29123ba3 100644 --- a/frontend/src/app/components/Onboarding/ac/ACGestures.ts +++ b/frontend/src/app/components/Onboarding/ac/ACGestures.ts @@ -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 { return new Promise((r) => window.setTimeout(r, ms)); } diff --git a/frontend/src/app/components/Onboarding/ac/ACPopup.tsx b/frontend/src/app/components/Onboarding/ac/ACPopup.tsx index f26a6471..c79a94bd 100644 --- a/frontend/src/app/components/Onboarding/ac/ACPopup.tsx +++ b/frontend/src/app/components/Onboarding/ac/ACPopup.tsx @@ -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 = ({ text, offset = { x: 0, y: 14 } }) => { const c = useClaudeTokens(); const { x, y, visible } = useCursorPosition(); @@ -64,19 +37,7 @@ const ACPopup: React.FC = ({ 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] ` 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( @@ -97,9 +58,7 @@ const ACPopup: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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. */} = ({ 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 = ({ text, offset = { x: 0, y: 14 } }) => { /> / 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 { - // 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; - // /