diff --git a/tui_gateway/agent_callbacks.py b/tui_gateway/agent_callbacks.py index 8f926bbb3c..0bca69cbad 100644 --- a/tui_gateway/agent_callbacks.py +++ b/tui_gateway/agent_callbacks.py @@ -176,7 +176,6 @@ def _wire_callbacks(sid: str): from tools.terminal_tool import set_sudo_password_callback from tools.skills_tool import set_secret_capture_callback from tools.project_tools import set_project_workspace_callback - set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120)) set_project_workspace_callback(_apply_project_workspace) @@ -188,7 +187,6 @@ def _wire_callbacks(sid: str): if not val: return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"} from hermes_cli.config import save_env_value_secure - return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"} set_secret_capture_callback(secret_cb) @@ -197,7 +195,6 @@ def _wire_callbacks(sid: str): def _available_personalities(cfg: dict | None = None) -> dict: """Built-ins + user overrides, via hermes_cli.personality (single owner).""" from hermes_cli.personality import available_personalities - return available_personalities(_load_cfg() if cfg is None else cfg) @@ -206,7 +203,6 @@ def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str contract as hermes_cli.personality.resolve_personality, but goes through the module-level _available_personalities so tests keep a single patch point.""" from hermes_cli.personality import normalize_personality_name, render_personality_prompt - name = normalize_personality_name(value) if not name: return "", "" @@ -220,7 +216,6 @@ def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str def _prompt_text(value) -> str: """Normalize config prompt values from YAML for AIAgent (hermes_cli.personality owns this).""" from hermes_cli.personality import prompt_text - return prompt_text(value) @@ -284,7 +279,6 @@ def _load_fallback_model(): """Configured fallback chain via the shared ``get_fallback_chain`` (parity with HermesCLI/gateway: ``fallback_providers`` first, legacy ``fallback_model`` merged after).""" from hermes_cli.fallback_config import get_fallback_chain - return get_fallback_chain(_load_cfg()) @@ -302,7 +296,8 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: return getattr(agent, name, default) kwargs = {k: g(k) or None for k in ( - "base_url", "api_key", "provider", "api_mode", "acp_command", "acp_args", "ephemeral_system_prompt")} + "base_url", "api_key", "provider", "api_mode", "acp_command", "acp_args", + "ephemeral_system_prompt")} kwargs.update({k: g(k) for k in ( "providers_allowed", "providers_ignored", "providers_order", "provider_sort", "provider_data_collection", "openrouter_min_coding_score")}) @@ -329,15 +324,18 @@ def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict: return kwargs +_PREVIEW_HISTORY_ROLES = ("user", "assistant", "tool", "system") + + def _preview_restart_history(session: dict, max_messages: int = 24, max_tool_chars: int = 1200) -> list[dict]: """Distill recent parent history for the ephemeral preview-restart agent (else it guesses app/server/cwd/port from the bare URL). Keeps the last ``max_messages`` (always back to the last user turn); tool results truncated to ``max_tool_chars``.""" try: with session["history_lock"]: - history = list(session.get("history", []) or []) + history = list(session.get("history") or []) except Exception: - history = list(session.get("history", []) or []) + history = list(session.get("history") or []) if not history: return [] start = max(0, len(history) - max_messages) @@ -347,15 +345,12 @@ def _preview_restart_history(session: dict, max_messages: int = 24, max_tool_cha break trimmed: list[dict] = [] for msg in history[start:]: - if not isinstance(msg, dict): - continue - if msg.get("role") not in ("user", "assistant", "tool", "system"): + if not isinstance(msg, dict) or msg.get("role") not in _PREVIEW_HISTORY_ROLES: continue copy = {k: v for k, v in msg.items() if k != "reasoning"} - if msg.get("role") == "tool": - content = copy.get("content") - if isinstance(content, str) and len(content) > max_tool_chars: - copy["content"] = content[:max_tool_chars] + f"\n... (truncated, original {len(content)} chars)" + content = copy.get("content") + if msg.get("role") == "tool" and isinstance(content, str) and len(content) > max_tool_chars: + copy["content"] = content[:max_tool_chars] + f"\n... (truncated, original {len(content)} chars)" trimmed.append(copy) return trimmed diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index f9472f4c15..bc980eba27 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -45,7 +45,6 @@ def _install_sidecar_publisher() -> None: if not url: return from tui_gateway.event_publisher import WsPublisherTransport - server._stdio_transport = TeeTransport(server._stdio_transport, WsPublisherTransport(url)) @@ -68,7 +67,6 @@ def _mcp_startup_call(name: str, *args, default=None, **kwargs): """Call ``hermes_cli.mcp_startup.`` (lazy import); ``default`` on any failure.""" try: from hermes_cli import mcp_startup - return getattr(mcp_startup, name)(*args, **kwargs) except Exception: return default @@ -118,7 +116,6 @@ def _log_signal(signum: int, frame) -> None: # messages reach state.db before the hard-exit timer fires. with suppress(Exception): from tui_gateway.server import _shutdown_sessions - _shutdown_sessions() # Unwind the main thread so atexit + finalisers run inside the grace window; @@ -176,7 +173,8 @@ def wait_for_mcp_discovery(timeout: "float | None" = None) -> None: thread = _mcp_discovery_thread if thread is not None and thread.is_alive(): fallback = timeout if timeout is not None else 0.75 - thread.join(timeout=_mcp_startup_call("_resolve_discovery_timeout", timeout, default=fallback)) + bound = _mcp_startup_call("_resolve_discovery_timeout", timeout, default=fallback) + thread.join(timeout=bound) return # Shared-owner path. Re-invoke the idempotent spawn first so a previous # zero-connected run gets its retry instead of latching the process MCP-less. @@ -187,7 +185,6 @@ def wait_for_mcp_discovery(timeout: "float | None" = None) -> None: return try: from hermes_cli.mcp_startup import start_background_mcp_discovery - start_background_mcp_discovery(logger=logger, thread_name="tui-mcp-discovery") except Exception: logger.debug("TUI MCP discovery retry-spawn failed", exc_info=True) @@ -224,7 +221,6 @@ _recovery_times: list[float] = [] def _has_configured_mcp_servers() -> bool: """Delegate to the shared native and portable MCP startup gate.""" from hermes_cli.mcp_startup import _has_configured_mcp_servers as configured - return configured() @@ -245,7 +241,6 @@ def ensure_mcp_discovery_started() -> None: _mcp_discovery_enabled = True try: from hermes_cli.mcp_startup import start_background_mcp_discovery - start_background_mcp_discovery(logger=logger, thread_name="tui-mcp-discovery") except Exception: logger.warning("Background MCP tool discovery failed to start", exc_info=True) diff --git a/tui_gateway/mcp_oauth_sessions.py b/tui_gateway/mcp_oauth_sessions.py index 8f4adaaddd..4a1f1c8a16 100644 --- a/tui_gateway/mcp_oauth_sessions.py +++ b/tui_gateway/mcp_oauth_sessions.py @@ -98,14 +98,42 @@ def _start_loopback_listener(flow) -> "http.server.HTTPServer": return httpd +def _probe_with_rollback( + server_name: str, cfg: dict, hermes_home: str, flow, reconnect_live: bool) -> None: + """Run the OAuth probe; on ANY failure restore the prior token file + manager entry.""" + from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server, _save_mcp_server + from tools.mcp_oauth import HermesTokenStorage + from tools.mcp_oauth_manager import get_manager + manager = get_manager() + storage = HermesTokenStorage(server_name) + backup = storage.snapshot() + previous_entry = None + try: + previous_entry = manager.remove(server_name, hermes_home=hermes_home) + timeout = max(float(cfg.get("connect_timeout", 0) or 0), 315) + tools = _probe_single_server(server_name, cfg, connect_timeout=timeout) + if not _oauth_tokens_present(server_name): + raise RuntimeError( + "The server responded, but no OAuth token was obtained — " + "this provider may require a manually-registered OAuth client.") + _save_mcp_server(server_name, cfg) + if flow is not None: + flow.tools = [{"name": t, "description": d} for t, d in tools] + flow.mark_approved() + if reconnect_live: + from tools.mcp_tool import reconnect_mcp_server + reconnect_mcp_server(server_name) + except Exception: + storage.restore(backup, only_if_absent=True) + manager.restore_entry(server_name, previous_entry, hermes_home=hermes_home) + raise + + def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reconnect_live: bool) -> None: """Drive the interactive MCP OAuth probe under the shared dashboard bridge (same - wrapping as ``web_server._run_dashboard_mcp_oauth``). On success the token file - exists and the server config is (re)saved; on failure the prior token/manager - state is restored.""" - from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server, _save_mcp_server + HERMES_HOME + secret-scope + force_interactive_oauth + dashboard_oauth_flow wrapping + as ``web_server._run_dashboard_mcp_oauth``), keyed to our session record.""" from hermes_constants import reset_hermes_home_override, set_hermes_home_override - rec = _sessions.get(session_id) flow = rec["flow"] if rec else None try: @@ -113,38 +141,11 @@ def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reco build_profile_secret_scope, reset_secret_scope, set_secret_scope) from tools.mcp_dashboard_oauth import dashboard_oauth_flow from tools.mcp_oauth import force_interactive_oauth - from tools.mcp_oauth_manager import get_manager - home_token = set_hermes_home_override(hermes_home) secret_token = set_secret_scope(build_profile_secret_scope(Path(hermes_home))) try: with force_interactive_oauth(), dashboard_oauth_flow(flow): - from tools.mcp_oauth import HermesTokenStorage - - manager = get_manager() - storage = HermesTokenStorage(server_name) - backup = storage.snapshot() - previous_entry = None - try: - previous_entry = manager.remove(server_name, hermes_home=hermes_home) - timeout = max(float(cfg.get("connect_timeout", 0) or 0), 315) - tools = _probe_single_server(server_name, cfg, connect_timeout=timeout) - if not _oauth_tokens_present(server_name): - raise RuntimeError( - "The server responded, but no OAuth token was obtained — " - "this provider may require a manually-registered OAuth client.") - _save_mcp_server(server_name, cfg) - if flow is not None: - flow.tools = [{"name": t, "description": d} for t, d in tools] - flow.mark_approved() - if reconnect_live: - from tools.mcp_tool import reconnect_mcp_server - - reconnect_mcp_server(server_name) - except Exception: - storage.restore(backup, only_if_absent=True) - manager.restore_entry(server_name, previous_entry, hermes_home=hermes_home) - raise + _probe_with_rollback(server_name, cfg, hermes_home, flow, reconnect_live) finally: reset_secret_scope(secret_token) reset_hermes_home_override(home_token) @@ -152,7 +153,6 @@ def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reco msg = str(exc) with suppress(Exception): from tools.mcp_oauth import humanize_oauth_registration_error - msg = humanize_oauth_registration_error( server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None ) or msg @@ -178,7 +178,6 @@ def start_flow( backend; invalid values raise ``ValueError``) no gateway-side listener is bound and the client relays ``code``/``state`` via ``deliver_callback_flow``.""" from tools.mcp_dashboard_oauth import DashboardOAuthFlow - if client_redirect_uri is not None: client_redirect_uri = _validate_client_redirect_uri(client_redirect_uri) diff --git a/tui_gateway/methods_projects.py b/tui_gateway/methods_projects.py index f290444384..68ad9bad34 100644 --- a/tui_gateway/methods_projects.py +++ b/tui_gateway/methods_projects.py @@ -167,12 +167,9 @@ def _is_repo_junk(root: str) -> bool: def _is_session_cwd_junk(cwd: str) -> bool: - """A non-git cwd that stays in flat Recents rather than auto-grouping. - - Unlike git roots, an explicitly selected DESCENDANT of HERMES_HOME may be an - intentional prose/data workspace (the pre-Projects desktop surfaced every - cwd), so only HERMES_HOME itself and ``_non_workspace_dirs`` are excluded. - """ + """A non-git cwd that stays in flat Recents rather than auto-grouping. Unlike git + roots, a selected DESCENDANT of HERMES_HOME may be an intentional prose/data + workspace, so only HERMES_HOME itself and ``_non_workspace_dirs`` are excluded.""" if not cwd: return True from hermes_constants import get_hermes_home @@ -192,7 +189,8 @@ def _repo_discovery_policy(raw: dict | None = None) -> dict: def _get(short: str, long: str): return source.get(short, source.get(long, defaults[long])) - def _paths(values, long: str) -> list[str]: + def _paths(short: str, long: str) -> list[str]: + values = _get(short, long) if not isinstance(values, list): return list(defaults[long]) return [v.strip() for v in values if isinstance(v, str) and v.strip()] @@ -200,9 +198,8 @@ def _repo_discovery_policy(raw: dict | None = None) -> dict: enabled = _get("enabled", "repo_scan_enabled") return { "enabled": enabled if isinstance(enabled, bool) else defaults["repo_scan_enabled"], - "roots": _paths(_get("roots", "repo_scan_roots"), "repo_scan_roots"), - "exclude_paths": _paths( - _get("exclude_paths", "repo_scan_exclude_paths"), "repo_scan_exclude_paths"), + "roots": _paths("roots", "repo_scan_roots"), + "exclude_paths": _paths("exclude_paths", "repo_scan_exclude_paths"), } @@ -245,9 +242,8 @@ def _scan_discovered_repos_remote(conn, policy: dict) -> bool: return any(path == ex or path.startswith(ex.rstrip("/\\") + os.sep) for ex in excludes if ex) for root in roots: if not os.path.isdir(root): - # `os.walk` on a missing root yields nothing instead of raising; an - # unmounted volume would look like an empty scan and let the - # authoritative replace wipe every cached repo under it. + # `os.walk` on a missing root yields nothing instead of raising; an unmounted + # volume would look like an empty scan and let the replace wipe its cache. authoritative = False logger.debug("discover_repos scan root missing, skipping: %s", root) continue @@ -255,23 +251,21 @@ def _scan_discovered_repos_remote(conn, policy: dict) -> bool: for dirpath, dirnames, _filenames in os.walk(root): if _is_excluded(dirpath): dirnames[:] = [] - continue - # Check `.git` BEFORE pruning hidden dirs — `.git` is itself hidden. - if ".git" in dirnames: + elif ".git" in dirnames: # check BEFORE pruning hidden dirs — `.git` is hidden if dirpath not in seen: seen.add(dirpath) pairs.append((dirpath, os.path.basename(dirpath))) dirnames[:] = [] # don't hunt nested repos inside a repo else: - dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in ("node_modules",)] + dirnames[:] = [ + d for d in dirnames if not d.startswith(".") and d != "node_modules"] if len(pairs) >= 500: break except Exception: authoritative = False logger.debug("discover_repos scan failed for root %s", root, exc_info=True) if len(pairs) >= 500: - # Cap hit: the walk didn't cover the full roots, so this set is not - # the complete authoritative universe. + # Cap hit: the walk didn't cover the full roots -> not authoritative. authoritative = False break if pairs: diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 67a6b6dd93..31c6dcaffb 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -39,12 +39,10 @@ def stamp_profile(projects: list[dict], profile: str) -> None: """Stamp every session row with the request-scope profile (authoritative even for legacy rows whose ``profile_name`` is NULL) for cross-profile routing.""" for project in projects: - for session in project.get("previewSessions") or []: + lanes = [g for repo in project.get("repos") or [] for g in repo.get("groups") or []] + lane_rows = [s for g in lanes for s in g.get("sessions") or []] + for session in (project.get("previewSessions") or []) + lane_rows: session["profile"] = profile - for repo in project.get("repos") or []: - for group in repo.get("groups") or []: - for session in group.get("sessions") or []: - session["profile"] = profile def _branch_lane_id(repo_root: str, branch: str = "") -> str: @@ -82,11 +80,11 @@ def _path_key(path: str) -> str: def _lane_key(path_or_lane: str) -> str: """Canonicalize only the path portion of a lane id; branch labels stay byte-preserved so equivalent Windows spellings don't fork lanes.""" - for marker in ("::branch::", "::kanban"): - if marker in path_or_lane: - root, suffix = path_or_lane.split(marker, 1) - return f"{_path_key(root)}{marker}{suffix}" - return _path_key(path_or_lane) + marker = next((m for m in ("::branch::", "::kanban") if m in path_or_lane), None) + if marker is None: + return _path_key(path_or_lane) + root, suffix = path_or_lane.split(marker, 1) + return f"{_path_key(root)}{marker}{suffix}" def base_name(path: str) -> str: @@ -248,18 +246,20 @@ def _disambiguate_labels(items: list[dict]) -> None: if len(pathed) < 2: continue parents = {id(g): _segments(g["path"])[:-1] for g in pathed} - max_depth = max(len(p) for p in parents.values()) - for depth in range(1, max_depth + 1): - counts: dict[str, int] = {} + for depth in range(1, max(len(p) for p in parents.values()) + 1): for g in pathed: prefix = "/".join(parents[id(g)][-depth:]) base = base_name(g["path"]) or g["path"] g["label"] = f"{prefix}/{base}" if prefix else base - counts[g["label"]] = counts.get(g["label"], 0) + 1 - if all(c == 1 for c in counts.values()): + if len({g["label"] for g in pathed}) == len(pathed): break +# Lane group wire fields <- placement keys (same order). +_LANE_FIELDS = ("id", "label", "path", "isMain", "isKanban") +_PLACEMENT_LANE_KEYS = ("lane_key", "lane_label", "lane_path", "is_main", "is_kanban") + + def _repo_node(root: str, label: str) -> dict: return {"id": root, "label": label, "path": root, "groups": [], "sessionCount": 0} @@ -273,11 +273,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool continue lane_identity = _lane_key(placement["lane_key"]) if lane_identity not in lanes: - group = { - "id": placement["lane_key"], "label": placement["lane_label"], - "path": placement["lane_path"], "isMain": placement["is_main"], - "isKanban": placement["is_kanban"], "sessions": [], - } + group = dict(zip(_LANE_FIELDS, (placement[k] for k in _PLACEMENT_LANE_KEYS))) + group["sessions"] = [] lanes[lane_identity] = (group, placement) lanes[lane_identity][0]["sessions"].append(session)