diff --git a/.github/scripts/run-workspace-checks.mjs b/.github/scripts/run-workspace-checks.mjs index eabc479877..0daa01d246 100644 --- a/.github/scripts/run-workspace-checks.mjs +++ b/.github/scripts/run-workspace-checks.mjs @@ -133,7 +133,9 @@ async function main() { if (failed.length > 0) { for (const r of failed) console.error(`::error::${r.unit.pkg} :: ${r.unit.script} failed`) console.error(`::error::${failed.length} of ${results.length} checks failed`) - process.exit(1) + // Not process.exit(): it drops what stdout still buffers, which is the failing check's output. + process.exitCode = 1 + return } console.log(`\nall ${results.length} checks passed`) } diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b7882e7bda..3c131afce9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -204,9 +204,10 @@ jobs: # --------------------------------------------------------------------- # The stamp above marks the checkout as a docker distribution, and PM # then expects the image's packaged runtime. The runner is not the - # image: drop it before provisioning the test toolchain. - - name: Remove the image install stamp from the checkout - run: rm -f install-stamp.json + # image: park the stamp while the test toolchain is provisioned, then + # put it back — tests/docker compares the image's provenance against it. + - name: Park the image install stamp while provisioning the runner toolchain + run: mv install-stamp.json "$RUNNER_TEMP/install-stamp.json" - name: Set up locked Python and test dependencies uses: ./.github/actions/setup-pm @@ -214,6 +215,9 @@ jobs: extras: '["dev"]' prune-python-cache: true + - name: Restore the image install stamp for the docker tests + run: mv "$RUNNER_TEMP/install-stamp.json" install-stamp.json + - name: Run docker integration tests env: # Skip rebuild; use the image already loaded by the build step. diff --git a/agent/agent_init.py b/agent/agent_init.py index 7c842f3620..b45914fe46 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1910,6 +1910,11 @@ def _enforce_minimum_context(agent): # Reject windows below the 64K floor needed for reliable tool-calling; an explicit # positive model.context_length on LM Studio is allowed below the floor. _ctx = getattr(agent.context_compressor, "context_length", 0) + # A local Ollama server serves num_ctx, not the GGUF's advertised window: a Modelfile or + # model.ollama_num_ctx at 64K+ is a usable window even when the metadata says 40K (#100437). + # Only a local endpoint can honour num_ctx, so a stale override never admits a hosted model. + if agent._ollama_num_ctx and agent.base_url and is_local_endpoint(agent.base_url): + _ctx = max(_ctx or 0, agent._ollama_num_ctx) _allow_lmstudio_explicit_below_floor = ( str(agent.provider or "").strip().lower() == "lmstudio" and isinstance(agent._config_context_length, int) @@ -2036,6 +2041,9 @@ def _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length): "Ollama num_ctx: will request %d tokens (model max from /api/show)", agent._ollama_num_ctx, ) + + +def _clamp_compressor_to_ollama_num_ctx(agent): # Recalibrate the compressor to the served window: every request runs at num_ctx, so a # trigger derived from the probed model window could sit above it and never fire. # A config that sets only model.ollama_num_ctx (without model.context_length) previously left the @@ -2324,11 +2332,12 @@ def init_agent( agent, _agent_cfg, base_url ) _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_context_length, session_db) + _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length) _enforce_minimum_context(agent) _warn_nonagentic_hermes_model(agent) _inject_context_engine_tools(agent) _init_usage_state(agent) - _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length) + _clamp_compressor_to_ollama_num_ctx(agent) _emit_compression_summary(agent, cs) _snapshot_primary_runtime(agent) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 02f298bcde..093c9d5338 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2961,6 +2961,58 @@ def looks_like_codex_intermediate_ack( ) +# Degenerate-final detector (#103483): after real tool work a text stop whose ENTIRE answer is a +# fragment — a stray wrong-script word ("пар" in an English conversation), a token starting +# mid-punctuation ("?warming up") — is a provider-side collapse, not an answer, yet the loop +# accepted it and the turn reported completed. Shape alone cannot PROVE a collapse, so this is +# deliberately narrower than "short": a terse legitimate answer ("42", "SQLite", "report.csv", +# "€12.50", "你好。", "Done.", ":8080", "да" to a Russian prompt) never matches, English-script +# fragments ("the", "ing") are knowingly not covered, and the re-prompt it triggers asks for the +# same answer again if it was complete. ``turn_finalizer._SENTENCE_END`` encodes a sibling +# "≤ 24 chars, no terminal" heuristic for the finish explainer. +_DEGENERATE_FINAL_MAX_CHARS = 24 +_SENTENCE_TERMINALS = (".", "!", "?", "\u3002", "\uff01", "\uff1f") +# Punctuation no answer begins with when a letter follows ("?warming"); "$5", "#123", "-1", +# "/tmp", ".env", "(a)", ":8080", ":)", ";;" all stay answers. +_DEGENERATE_LEADING_PUNCT = "?!,;:)]}" + + +def looks_like_degenerate_final(text: str, user_message: Any = None) -> bool: + """Whether a text stop reads as a collapsed fragment rather than a (terse) answer. + + "Wrong script" is judged against the conversation: when the user's own message carries + non-ASCII letters, a terse non-Latin reply ("是", "Готово") is an answer, not a collapse. + """ + t = (text or "").strip() + if not t or len(t) > _DEGENERATE_FINAL_MAX_CHARS or t.endswith(_SENTENCE_TERMINALS): + return False + if t[0] in _DEGENERATE_LEADING_PUNCT and len(t) > 1 and t[1].isalpha(): + return True + if not any(ch.isalpha() for ch in t) or any(ch.isascii() and ch.isalnum() for ch in t): + return False + from agent.codex_responses_adapter import _summarize_user_message_for_log + user_text = _summarize_user_message_for_log(user_message) if user_message else "" + return not any(ch.isalpha() and not ch.isascii() for ch in user_text) + + +def tool_results_this_turn(messages: List[Dict[str, Any]]) -> int: + """Tool-result rows after the most recent user row — whether the turn did real tool work. + + ANY user row ends the window, the continuation nudges included: that is what bounds the + degenerate-final guard to one re-prompt per collapse. Skipping synthetic user rows here + would turn it into a two-nudge loop. + """ + count = 0 + for msg in reversed(messages or ()): + if not isinstance(msg, dict): + continue + if msg.get("role") == "user": + break + if msg.get("role") == "tool": + count += 1 + return count + + # Narrow "trailing continue-intent" detector for the stall guard (agent.stall_guards): only the # message TAIL announcing a next action, so mid-sentence "I will" never trips it. _TRAILING_CONTINUE_INTENT_RE = re.compile( diff --git a/agent/anthropic_credentials.py b/agent/anthropic_credentials.py index 27de5c798e..f56651c774 100644 --- a/agent/anthropic_credentials.py +++ b/agent/anthropic_credentials.py @@ -17,6 +17,7 @@ import json import logging import os import platform +import re import secrets import subprocess import threading @@ -41,6 +42,10 @@ _OAUTH_TOKEN_URLS = [ _OAUTH_TOKEN_USER_AGENT = "axios/1.7.9" _OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback" _OAUTH_SCOPES = "org:create_api_key user:profile user:inference" +# Claude Code's macOS Keychain entry (generic password). Hermes reads it +# (_read_claude_code_credentials_from_keychain) and, since #98334, mirrors the +# refresh write into it so the two stores stop diverging on a single-use rotation. +_CLAUDE_CODE_KEYCHAIN_SERVICE = "Claude Code-credentials" def _getenv(name: str, default: str = "") -> str: @@ -206,27 +211,103 @@ def _claude_oauth_record(data: Any, source: str) -> Optional[Dict[str, Any]]: } -def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]: - """Read the "Claude Code-credentials" macOS Keychain entry (Claude Code >=2.1.114).""" +_KEYCHAIN_ATTR = r'(?:0x(?P[0-9A-Fa-f]+)\b.*|"(?P.*)")' + + +def _decode_keychain_attr(match: Optional["re.Match[str]"]) -> str: + """``security`` prints an attribute as ``"text"`` when it is plain printable ASCII and as + ``0x ""`` otherwise; the quoted form is NOT escaped (an embedded + ``"`` appears raw), so the text group must run to the last quote on the line.""" + if match is None: + return "" + if match.group("hex"): + try: + return bytes.fromhex(match.group("hex")).decode("utf-8") + except ValueError: + return "" + return match.group("text") or "" + + +def _find_claude_code_keychain_item() -> Optional[tuple[str, Dict[str, Any]]]: + """``(account, payload)`` of the ``Claude Code-credentials`` login Keychain item, or None. + + One ``find-generic-password -g`` call: attributes on stdout, ``password: …`` on stderr. The + account matters because ``add-generic-password -U`` matches on account AND service — writing + under another account would create a second item instead of updating the one Claude Code reads. + """ if platform.system() != "Darwin": return None try: result = subprocess.run( - ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], + ["security", "find-generic-password", "-s", _CLAUDE_CODE_KEYCHAIN_SERVICE, "-g"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, stdin=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + account = _decode_keychain_attr(re.search(r'^\s*"acct"=' + _KEYCHAIN_ATTR + r"\s*$", result.stdout, re.M)) + raw = _decode_keychain_attr(re.search(r"^password: " + _KEYCHAIN_ATTR + r"\s*$", result.stderr, re.M)) + if not account or not raw: + return None + try: + payload = json.loads(raw) + except ValueError: + return None + return (account, payload) if isinstance(payload, dict) else None + + +def _read_claude_code_keychain_payload() -> Optional[Dict[str, Any]]: + """Raw ``{"claudeAiOauth": {...}, ...}`` payload from the macOS Keychain, or None. + + Returns the full entry (not the normalised credential record) so a refresh + write can merge the rotated token triple over the existing metadata + (``subscriptionType`` / ``rateLimitTier`` / ``scopes``) instead of clobbering it. + """ + if platform.system() != "Darwin": + return None + try: + result = subprocess.run( + ["security", "find-generic-password", "-s", _CLAUDE_CODE_KEYCHAIN_SERVICE, "-w"], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, stdin=subprocess.DEVNULL, ) except (OSError, subprocess.TimeoutExpired): logger.debug("Keychain: security command not available or timed out") return None if result.returncode != 0: - logger.debug("Keychain: no entry found for 'Claude Code-credentials'") + logger.debug("Keychain: no entry found for %r", _CLAUDE_CODE_KEYCHAIN_SERVICE) return None raw = result.stdout.strip() + if not raw: + return None try: - return _claude_oauth_record(json.loads(raw), "macos_keychain") if raw else None + payload = json.loads(raw) except json.JSONDecodeError: logger.debug("Keychain: credentials payload is not valid JSON") return None + return payload if isinstance(payload, dict) else None + + +def _keychain_mirror_command(account: str, payload: Dict[str, Any]) -> tuple[list[str], str]: + """``(argv, stdin)`` that updates the Claude Code Keychain item with ``payload``. + + The command line goes to ``security -i`` on stdin, with the secret hex-encoded (``-X``): + a bare ``-w`` prompts twice on /dev/tty when a terminal exists (hangs the CLI) and, with + no terminal, reads only the first line and stores an EMPTY password when the confirmation + read hits EOF — either way the live token must never sit on argv. + """ + def quoted(value: str) -> str: # the ``security -i`` tokenizer: double quotes, backslash escapes + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + encoded = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8").hex() + line = f"add-generic-password -U -a {quoted(account)} -s {quoted(_CLAUDE_CODE_KEYCHAIN_SERVICE)} -X {encoded}\n" + return ["security", "-i"], line + + +def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]: + """Read the "Claude Code-credentials" macOS Keychain entry (Claude Code >=2.1.114).""" + payload = _read_claude_code_keychain_payload() + return _claude_oauth_record(payload, "macos_keychain") if payload else None def claude_code_credentials_path() -> Path: @@ -403,7 +484,10 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]: # The POST spent ``refresh_token``; this write is the commit step. On failure, fail closed and # mark the pre-rotation pair as spent. try: - _write_claude_code_credentials(refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"]) + _write_claude_code_credentials( + refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"], + spent_refresh_token=refresh_token, + ) except Exception as e: logger.error( "Anthropic OAuth refresh rotated the single-use token but could not " @@ -425,7 +509,8 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]: def _write_claude_code_credentials( - access_token: str, refresh_token: str, expires_at_ms: int, *, scopes: Optional[list] = None + access_token: str, refresh_token: str, expires_at_ms: int, *, scopes: Optional[list] = None, + spent_refresh_token: str = "", ) -> None: """Commit refreshed credentials to ~/.claude/.credentials.json; ``CredentialPersistError`` on any failure (a corrupt existing file included). *scopes* (or the previously stored scopes) are persisted because Claude Code @@ -443,6 +528,57 @@ def _write_claude_code_credentials( oauth_data["scopes"] = existing["claudeAiOauth"]["scopes"] existing["claudeAiOauth"] = oauth_data _commit_private_json(cred_path, existing, "credentials") + _mirror_claude_code_credentials_to_keychain( + access_token, refresh_token, expires_at_ms, spent_refresh_token=spent_refresh_token) + + +def _merge_keychain_credential_payload( + existing_payload: Dict[str, Any], access_token: str, refresh_token: str, expires_at_ms: int +) -> Dict[str, Any]: + """Rotate the ``claudeAiOauth`` token triple over the existing Keychain payload, + preserving its metadata (``subscriptionType`` / ``rateLimitTier`` / ``scopes``). + + Pure and host-agnostic so the merge semantics are unit-testable without a Keychain. + """ + merged = dict(existing_payload) + oauth = dict(existing_payload.get("claudeAiOauth") or {}) + oauth.update({"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms}) + merged["claudeAiOauth"] = oauth + return merged + + +def _mirror_claude_code_credentials_to_keychain( + access_token: str, refresh_token: str, expires_at_ms: int, *, spent_refresh_token: str +) -> None: + """After a Hermes refresh, write the rotated pair into the Claude Code Keychain item too (#98334). + + Claude Code on macOS reads the login Keychain first. Refresh tokens are single-use, so a refresh + that only updates the file leaves the Keychain holding a spent token and Claude Code logs itself + out. Only the item that held the pair we just spent is updated — a different pair there means a + different login (``CLAUDE_CONFIG_DIR``) or a rotation Claude Code already made, and clobbering it + would be the bug in the other direction. Best-effort: never raises, never creates an item. + """ + if platform.system() != "Darwin": + return + try: + item = _find_claude_code_keychain_item() + if item is None: + return + account, existing = item + oauth = existing.get("claudeAiOauth") + if not isinstance(oauth, dict) or oauth.get("refreshToken") != spent_refresh_token: + logger.debug("Keychain mirror skipped: item does not hold the pair that was just rotated") + return + argv, line = _keychain_mirror_command( + account, _merge_keychain_credential_payload(existing, access_token, refresh_token, expires_at_ms)) + result = subprocess.run( + argv, input=line, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, + ) + except Exception as e: # the file commit already succeeded; a Keychain hiccup must not fail the rotation + logger.debug("Keychain mirror skipped (%s)", e) + return + if result.returncode != 0: + logger.debug("Keychain mirror failed (rc=%s): %s", result.returncode, (result.stderr or "").strip()[:200]) # ── Resolution ── diff --git a/agent/context_compressor.py b/agent/context_compressor.py index e2750fc09d..6c03642e49 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -3771,15 +3771,15 @@ Write only the summary body. Do not include any preamble or prefix.""" text = _content_text_for_contains(message.get("content")).strip() # Recovery nudges are scaffolding, not human turns; lazy import avoids an import cycle. from agent.conversation_loop import ( - _CODEX_ACK_CONTINUATION_NUDGE, _CODEX_INCOMPLETE_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT, - _EMPTY_TOOL_RESPONSE_NUDGE, _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX, _LENGTH_CONTINUATION_NETWORK_STUB, - _LENGTH_CONTINUATION_OUTPUT_LIMIT, + _CODEX_ACK_CONTINUATION_NUDGE, _CODEX_INCOMPLETE_NUDGE, _DEGENERATE_FINAL_NUDGE, + _DROPPED_TOOLCALL_NUDGE_CONTENT, _EMPTY_TOOL_RESPONSE_NUDGE, _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX, + _LENGTH_CONTINUATION_NETWORK_STUB, _LENGTH_CONTINUATION_OUTPUT_LIMIT, ) return text in { COMPRESSION_CONTINUATION_USER_CONTENT, _LEGACY_COMPRESSION_CONTINUATION_USER_CONTENT, MAX_ITERATIONS_SUMMARY_REQUEST, _CODEX_INCOMPLETE_NUDGE, _CODEX_ACK_CONTINUATION_NUDGE, - _DROPPED_TOOLCALL_NUDGE_CONTENT, _EMPTY_TOOL_RESPONSE_NUDGE, _LENGTH_CONTINUATION_NETWORK_STUB, - _LENGTH_CONTINUATION_OUTPUT_LIMIT, + _DEGENERATE_FINAL_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT, _EMPTY_TOOL_RESPONSE_NUDGE, + _LENGTH_CONTINUATION_NETWORK_STUB, _LENGTH_CONTINUATION_OUTPUT_LIMIT, } or text.startswith(( _BACKGROUND_PROCESS_NOTIFICATION_PREFIX, TODO_INJECTION_HEADER + "\n", _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX, )) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 4ce7008935..2343c74547 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -870,6 +870,14 @@ _CODEX_ACK_CONTINUATION_NUDGE = ( "after completing the task.]" ) +# Re-prompt after a collapsed fragment ended a turn that had done real tool work (#103483). Asks +# for the same answer again when it WAS complete, so a false positive costs one call, never the answer. +_DEGENERATE_FINAL_NUDGE = ( + "[System: Your previous message ended the turn with a fragment that is not a usable answer. " + "If the task is unfinished, continue it and then give the complete answer. If that fragment " + "WAS your complete answer, send it again exactly as before.]" +) + # Re-prompt for finish_reason="tool_calls" with empty tool_calls (an interrupt mid-retry can persist it). _DROPPED_TOOLCALL_NUDGE_CONTENT = ( "Your previous turn indicated a tool call but none was included. Do not narrate a plan or " diff --git a/agent/credential_pool.py b/agent/credential_pool.py index ede5f7d0e1..4eee11c0e3 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1372,7 +1372,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin) from agent import anthropic_credentials as ac args = (refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"]) if entry.source == "claude_code": - ac._write_claude_code_credentials(*args) + ac._write_claude_code_credentials(*args, spent_refresh_token=entry.refresh_token or "") else: ac._write_hermes_oauth_credentials(*args) except Exception as wexc: diff --git a/agent/lsp/__init__.py b/agent/lsp/__init__.py index 933655b00f..7468e131ab 100644 --- a/agent/lsp/__init__.py +++ b/agent/lsp/__init__.py @@ -63,6 +63,20 @@ def get_service() -> Optional[LSPService]: return _active(_service) +def release_workspace(path: str) -> int: + """Shut down the LSP clients serving ``path`` (a worktree about to be removed) in every started + service, without shutting down unrelated workspaces. Never creates a service. Returns the count.""" + with _service_lock: + services = [svc for svc in (_service, *_services_by_home.values()) if svc is not None] + released = 0 + for svc in services: + try: + released += svc.release_workspace(path) + except Exception as e: # noqa: BLE001 + logger.debug("LSP workspace release failed for %s: %s", path, e) + return released + + def shutdown_service() -> None: """Tear down every LSP service that was started. Idempotent.""" global _service @@ -86,4 +100,4 @@ def _atexit_shutdown() -> None: logger.debug("atexit LSP shutdown failed: %s", e) -__all__ = ["get_service", "shutdown_service", "LSPService"] +__all__ = ["get_service", "release_workspace", "shutdown_service", "LSPService"] diff --git a/agent/lsp/cli.py b/agent/lsp/cli.py index bc03021ed5..6fcb2bd91e 100644 --- a/agent/lsp/cli.py +++ b/agent/lsp/cli.py @@ -56,20 +56,37 @@ def run_lsp_command(args: argparse.Namespace) -> int: return 130 +def _all_servers() -> list: + """Config-declared servers (``lsp.servers..extensions``) ahead of the built-in registry.""" + from agent.lsp.servers import SERVERS, custom_servers + from hermes_cli.config import load_config_readonly + try: + lsp_cfg = load_config_readonly().get("lsp") or {} + except Exception: # noqa: BLE001 — a broken config still lists the built-ins + lsp_cfg = {} + return [*custom_servers(lsp_cfg.get("servers") if isinstance(lsp_cfg, dict) else None), *SERVERS] + + def _status_for(server_id: str) -> str: + import os from agent.lsp.install import detect_status + from agent.lsp.servers import SERVERS, ServerContext + custom = next((s for s in _all_servers() if s.server_id == server_id and s not in SERVERS), None) + if custom is not None: # no install recipe: installed iff the configured command resolves + cwd = os.getcwd() + return "installed" if custom.build_spawn(cwd, ServerContext(cwd, install_strategy="manual")) else "manual-only" return detect_status(_recipe_pkg_for(server_id)) def _cmd_status(emit_json: bool) -> int: from agent.lsp import get_service - from agent.lsp.servers import SERVERS + servers = _all_servers() svc = get_service() info = svc.get_status() if svc is not None else {"enabled": False} if emit_json: import json registry = [{"server_id": s.server_id, "extensions": list(s.extensions), "description": s.description, - "binary_status": _status_for(s.server_id)} for s in SERVERS] + "binary_status": _status_for(s.server_id)} for s in servers] sys.stdout.write(json.dumps({"service": info, "registry": registry}, indent=2) + "\n") return 0 @@ -91,7 +108,7 @@ def _cmd_status(emit_json: bool) -> int: if backend_warnings := _backend_warnings(): out += ["", "Backend warnings", "================"] + [f" ! {line}" for line in backend_warnings] out += ["", "Registered Servers", "=================="] - for s in SERVERS: + for s in servers: status = _status_for(s.server_id) ext_summary = ", ".join(list(s.extensions)[:5]) if len(s.extensions) > 5: @@ -104,8 +121,7 @@ def _cmd_status(emit_json: bool) -> int: def _cmd_list(installed_only: bool) -> int: - from agent.lsp.servers import SERVERS - for s in SERVERS: + for s in _all_servers(): status = _status_for(s.server_id) if not (installed_only and status != "installed"): sys.stdout.write(f"{s.server_id:24s} [{status:11s}] {','.join(s.extensions)}\n") diff --git a/agent/lsp/client.py b/agent/lsp/client.py index ebc14cb682..eda3bc8b02 100644 --- a/agent/lsp/client.py +++ b/agent/lsp/client.py @@ -10,6 +10,7 @@ it), and ``ContentModified`` (-32801) errors are retried with exponential backof from __future__ import annotations import asyncio +import contextlib import logging import os import sys @@ -33,10 +34,15 @@ DIAGNOSTICS_DOCUMENT_WAIT = 5.0 DIAGNOSTICS_FULL_WAIT = 10.0 DIAGNOSTICS_REQUEST_TIMEOUT = 3.0 PUSH_DEBOUNCE = 0.15 -SHUTDOWN_GRACE = 1.0 # seconds between SIGTERM and SIGKILL +SHUTDOWN_GRACE = 1.0 # seconds after `exit` before SIGTERM, and between SIGTERM and SIGKILL # Retry policy for transient ContentModified errors: 0.5, 1.0, 2.0s. MAX_CONTENT_MODIFIED_RETRIES = 3 RETRY_BASE_DELAY = 0.5 +# Cap on tracked documents: each _DocState pins the file's full text here AND the server mirrors +# every open document, so an uncapped dict pins everything a long session ever touched on both +# sides of the pipe until the idle reaper kills the whole client (#62950). 64 covers an active +# edit loop's working set; evicted files are didClose'd and re-didOpen'ed on their next touch. +MAX_TRACKED_FILES = 64 _WRITE_ERRORS = (BrokenPipeError, ConnectionResetError, OSError) _LIVE_STATES = {"starting", "running"} @@ -283,6 +289,16 @@ class LSPClient: "workspace/didChangeWorkspaceFolders", {"event": {"added": [_folder(root)], "removed": []}}, ) + async def remove_workspace_folder(self, root: str) -> None: + """Detach ``root`` from a running multi-root server (a removed worktree) so the process keeps + serving its sibling roots instead of being torn down with them. Idempotent.""" + if root not in self.workspace_folders: + return + self.workspace_folders.remove(root) + await self._send_notification( + "workspace/didChangeWorkspaceFolders", {"event": {"added": [], "removed": [_folder(root)]}}, + ) + async def _initialize(self) -> None: params = { "rootUri": file_uri(self.workspace_root), "rootPath": self.workspace_root, "processId": os.getpid(), @@ -299,7 +315,8 @@ class LSPClient: await self._send_notification("workspace/didChangeConfiguration", {"settings": self._init_options}) async def shutdown(self) -> None: - """Best-effort graceful shutdown: ``shutdown`` + ``exit``, then SIGTERM/SIGKILL. Idempotent.""" + """Best-effort graceful shutdown: ``shutdown`` + ``exit``, wait ``SHUTDOWN_GRACE`` for the + server to honour ``exit``, then SIGTERM/SIGKILL. Idempotent.""" if self._stopping: return self._stopping = True @@ -313,6 +330,11 @@ class LSPClient: await self._send_notification("exit", None) except Exception: # noqa: BLE001 pass + # Signalling right after ``exit`` races the server's own exit: needless SIGTERM + # noise for well-behaved servers and, on Darwin, a reaped-and-reused PID target. + if (proc := self._proc) is not None and proc.returncode is None: + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(proc.wait(), timeout=SHUTDOWN_GRACE) finally: self._state = "stopped" await self._cleanup_process() @@ -491,12 +513,16 @@ class LSPClient: ) if doc is None: # Fresh state: anything a pre-open push stashed under this path (relatedDocuments spillover) is discarded. + self._docs.pop(abs_path, None) self._docs[abs_path] = _DocState(version=0, text=text) await self._send_notification( "textDocument/didOpen", {"textDocument": {"uri": uri, "languageId": language_id, "version": 0, "text": text}}, ) + await self._evict_lru_docs() return 0 + # pop + reinsert refreshes LRU recency (dicts are insertion-ordered). + self._docs[abs_path] = self._docs.pop(abs_path) change: Dict[str, Any] = {"text": text} if self._sync_kind == 2: change["range"] = {"start": {"line": 0, "character": 0}, "end": _end_position(doc.text)} @@ -513,6 +539,15 @@ class LSPClient: ) return new_version + async def _evict_lru_docs(self) -> None: + """Drop least-recently-touched documents beyond MAX_TRACKED_FILES; didClose the ones the server + has open so it releases its mirror too (version -1 entries were never opened).""" + while len(self._docs) > MAX_TRACKED_FILES: + old_path, old = next(iter(self._docs.items())) + del self._docs[old_path] + if old.version >= 0: + await self._send_notification("textDocument/didClose", {"textDocument": {"uri": file_uri(old_path)}}) + async def save_file(self, path: str) -> None: """Send didSave for ``path``. Some linters re-scan only on save.""" if self.is_running: diff --git a/agent/lsp/eventlog.py b/agent/lsp/eventlog.py index 69d0b9f22a..ef9cad99c6 100644 --- a/agent/lsp/eventlog.py +++ b/agent/lsp/eventlog.py @@ -6,7 +6,9 @@ skipped, repeat "no project root" / "server unavailable"); INFO for once-per-ses transitions (first ``active for ``, first ``no project root`` per file) and every diagnostic event; WARNING for action-required failures (first ``server unavailable`` per (server_id, binary), every timeout / unexpected error). Dedup uses module-level sets bounded -by the distinct pairs touched in one process — a bounded LRU would re-fire suppressed lines. +by the distinct pairs touched in one process, each capped at ``_ANNOUNCE_CAP`` keys: past it the +bucket resets and the first-seen line re-fires once, so per-file keys can't grow for the life of +a gateway process (#62950). """ from __future__ import annotations @@ -20,6 +22,7 @@ from typing import List, Tuple event_log = logging.getLogger("hermes.lint.lsp") _announce_lock = threading.Lock() +_ANNOUNCE_CAP = 512 _announced_active: set = set() # keys: (server_id, workspace_root) _announced_unavailable: set = set() # keys: (server_id, binary_path_or_name) _announced_no_root: set = set() # keys: (server_id, file_path) @@ -32,7 +35,9 @@ def _short_path(file_path: str) -> str: return file_path try: rel = os.path.relpath(file_path) - except ValueError: + except (ValueError, OSError): + # Different drive (ValueError) or the process cwd was removed (OSError from getcwd): + # a log-line shortener must never turn a delivered diagnostic into a swallowed error. return file_path return file_path if rel.startswith(".." + os.sep) or rel == ".." else rel @@ -45,6 +50,8 @@ def _emit_once(bucket: set, key: Tuple, server_id: str, level: int, first: str, """Log *first* at *level* the first time *key* is seen, *repeat* at DEBUG thereafter.""" with _announce_lock: is_first = key not in bucket + if is_first and len(bucket) >= _ANNOUNCE_CAP: + bucket.clear() bucket.add(key) _emit(server_id, level if is_first else logging.DEBUG, first if is_first else repeat) @@ -116,6 +123,15 @@ def log_reaped(keys: List[Tuple[str, str]], idle_timeout: float) -> None: _emit("reaper", logging.INFO, f"reaped {len(keys)} idle client(s) after {idle_timeout:.0f}s: {summary}") +def log_released(keys: List[Tuple[str, str]], reason: str) -> None: + """Clients were shut down because their workspace went away (worktree released or root deleted). + INFO, one line per event; forgets the ``log_active`` announcement like :func:`log_reaped`.""" + with _announce_lock: + _announced_active.difference_update(keys) + summary = ", ".join(f"{sid} ({root})" for sid, root in keys) + _emit("reaper", logging.INFO, f"released {len(keys)} client(s) ({reason}): {summary}") + + def reset_announce_caches() -> None: """Test-only: clear the dedup caches. Production code never calls this.""" with _announce_lock: diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 30913854ff..6710bf94c2 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -36,6 +36,10 @@ def _manual(bin_name: str) -> Dict[str, Any]: return _recipe("manual", "", bin_name) +# TypeScript 7+ is the Go-native port and ships no ``lib/tsserver.js`` / +# ``lib/typescript.js``, so JS-based servers cannot load it as their SDK. +TYPESCRIPT_SDK_PKG = "typescript@6" + # Recipe key → {strategy, pkg, bin[, extra_pkgs]}. After install we look for # ``bin`` in ``/lsp/bin/`` first, then on PATH. ``extra_pkgs`` # are sibling npm packages a server needs in the same node_modules tree. @@ -43,8 +47,12 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = { "pyright": _npm("pyright", "pyright-langserver"), # tsserver must be importable from the same node_modules tree or # initialize() fails with "Could not find a valid TypeScript installation". - "typescript-language-server": _npm("typescript-language-server", "typescript-language-server", extra_pkgs=["typescript"]), - "@vue/language-server": _npm("@vue/language-server", "vue-language-server"), + "typescript-language-server": _npm("typescript-language-server", "typescript-language-server", extra_pkgs=[TYPESCRIPT_SDK_PKG]), + # 3.x forwards every TypeScript request to a client-hosted tsserver + # (``tsserver/request`` tunnel) that a generic LSP client does not run, so + # it never publishes diagnostics; 2.x self-hosts TypeScript from + # ``initializationOptions.typescript.tsdk`` (see servers._spawn_vue). + "@vue/language-server": _npm("@vue/language-server@2", "vue-language-server", extra_pkgs=[TYPESCRIPT_SDK_PKG]), "svelte-language-server": _npm("svelte-language-server", "svelteserver"), "@astrojs/language-server": _npm("@astrojs/language-server", "astro-ls"), "yaml-language-server": _npm("yaml-language-server", "yaml-language-server"), @@ -57,6 +65,8 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = { "rust-analyzer": _manual("rust-analyzer"), "clangd": _manual("clangd"), "lua-language-server": _manual("lua-language-server"), + # laravel-lsp ships via composer (`composer global require laravel/lsp`), not npm. + "laravel-lsp": _manual("laravel-lsp"), # PowerShellEditorServices is a release-zip bundle driven by pwsh; we probe # the host so `hermes lsp status` reports its presence. "powershell": _manual("pwsh"), @@ -81,24 +91,40 @@ def hermes_lsp_bin_dir() -> Path: return p -def _native_binary_candidates(base: Path) -> list[Path]: - """Return platform-native executable candidates for a staged binary (``base`` plus Windows wrappers).""" - if not _is_windows(): +def _native_binary_candidates(base: Path, *, is_windows: Optional[bool] = None) -> list[Path]: + """Return platform-native executable candidates for a staged binary, most runnable first. + + On Windows the ``.cmd``/``.exe``/``.bat`` wrappers come BEFORE the bare name: npm writes a + POSIX ``#!/bin/sh`` shim under the bare name next to its ``.cmd``, ``os.access(X_OK)`` is + always true there, and ``CreateProcess`` on the shim fails with WinError 193. The bare name + stays as a last resort for genuinely extension-less executables. + """ + if not (_is_windows() if is_windows is None else is_windows): return [base] cands: Dict[str, Path] = {} - for c in (base, *(Path(str(base) + s) for s in _WINDOWS_WRAPPER_SUFFIXES)): + for c in (*(Path(str(base) + s) for s in _WINDOWS_WRAPPER_SUFFIXES), base): cands.setdefault(str(c).lower(), c) return list(cands.values()) -def _first_existing(*bases: Path) -> Optional[Path]: +def _first_existing(*bases: Path, is_windows: Optional[bool] = None) -> Optional[Path]: """First platform-native candidate of any ``base`` that exists on disk.""" - return next((c for base in bases for c in _native_binary_candidates(base) if c.exists()), None) + return next((c for base in bases for c in _native_binary_candidates(base, is_windows=is_windows) if c.exists()), None) -def _existing_binary(name: str) -> Optional[str]: - """Probe the staging dir + PATH for a binary named ``name``.""" - for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name): +def _npm_bin_dir() -> Path: + """npm's own ``node_modules/.bin`` under the staging tree, where its ``%~dp0``-relative wrappers work.""" + return hermes_lsp_bin_dir().parent / "node_modules" / ".bin" + + +def _existing_binary(name: str, *, is_windows: Optional[bool] = None) -> Optional[str]: + """Probe the staging dir (+ npm's bin dir on Windows) then PATH for a binary named ``name``. + + ``is_windows`` overrides the host check so the Windows resolution is testable as data on every lane. + """ + win = _is_windows() if is_windows is None else is_windows + bases = [hermes_lsp_bin_dir() / name] + ([_npm_bin_dir() / name] if win else []) + for staged in (c for base in bases for c in _native_binary_candidates(base, is_windows=win)): if staged.exists() and os.access(staged, os.X_OK): return str(staged) if any(r.get("strategy") == "pip" and r.get("bin") == name for r in INSTALL_RECIPES.values()): @@ -111,7 +137,7 @@ def _existing_binary(name: str) -> Optional[str]: else: if binary is not None: return str(binary) - suffixes = ("", *_WINDOWS_WRAPPER_SUFFIXES) if _is_windows() else ("",) + suffixes = (*_WINDOWS_WRAPPER_SUFFIXES, "") if win else ("",) return next((p for s in suffixes if (p := shutil.which(f"{name}{s}"))), None) @@ -159,7 +185,9 @@ def _run_installer(tool: str, pkg: str, cmd: list, *, timeout: int, env: Optiona timeout=timeout, env=env, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(), ) if proc.returncode != 0: - logger.warning("[install] %s install failed for %s: %s", tool, pkg, proc.stderr.strip()[:500]) + # pnpm reports ERR_PNPM_* on stdout with an empty stderr; log whichever stream carries the reason. + detail = (proc.stderr.strip() or proc.stdout.strip())[:500] + logger.warning("[install] %s install failed for %s: %s", tool, pkg, detail) return False except (subprocess.TimeoutExpired, OSError) as e: logger.warning("[install] %s install errored for %s: %s", tool, pkg, e) @@ -182,22 +210,58 @@ def _link_into_bin(target: Path) -> str: return str(link if link.exists() else target) +# Node package manager → argv that installs into ``/node_modules`` (``lsp.package_manager``). +# Every manager keeps the staging-dir semantics: nothing touches the user's project or global tree. +_NODE_PM_ARGV: Dict[str, Callable[[str], list]] = { + "npm": lambda staging: ["install", "--prefix", staging, "--silent", "--no-fund", "--no-audit"], + "pnpm": lambda staging: ["add", "--dir", staging], + # Global ``--cwd`` (before the command) is accepted by both Yarn Classic and Yarn Berry; Berry's + # default PnP linker writes no ``node_modules/.bin``, so the staging dir needs ``nodeLinker: node-modules``. + "yarn": lambda staging: ["--cwd", staging, "add"], +} + + +def _node_package_manager() -> Optional[str]: + """``lsp.package_manager`` from config (npm default); an unknown value fails closed (``None``).""" + try: + from hermes_cli.config import load_config_readonly + lsp_cfg = load_config_readonly().get("lsp") or {} + except Exception: # noqa: BLE001 — installer must not die on a broken config; npm is the historical default + return "npm" + pm = str(lsp_cfg.get("package_manager") or "npm").strip().lower() if isinstance(lsp_cfg, dict) else "npm" + if pm not in _NODE_PM_ARGV: + # Fail closed: a typo must not silently bypass a pnpm/yarn supply-chain policy by running npm. + logger.warning("[install] lsp.package_manager=%r is not one of %s; skipping install", pm, sorted(_NODE_PM_ARGV)) + return None + return pm + + def _install_npm(pkg: str, bin_name: str, extra_pkgs: Optional[list] = None) -> Optional[str]: - """``npm install --prefix `` then link ``node_modules/.bin/`` into ``lsp/bin/``.""" - npm = find_node_executable("npm") - if npm is None: - logger.info("[install] cannot install %s: no usable npm found", pkg) + """Install with the configured Node package manager into ```` and link + ``node_modules/.bin/`` into ``lsp/bin/``.""" + pm = _node_package_manager() + if pm is None: + return None + # Managed Node first: $HERMES_HOME/node isn't on an arbitrary process's + # PATH, so a bare which() would miss the Node that Hermes installed. + pm_bin = find_node_executable(pm) + if pm_bin is None: + # Deliberately no silent fallback to npm: a pnpm/yarn choice is usually a supply-chain policy. + logger.warning("[install] cannot install %s: lsp.package_manager is %r but no usable %s was found " + "(install it, or set lsp.package_manager: npm)", pkg, pm, pm) return None staging = hermes_lsp_bin_dir().parent # /lsp/ install_targets = [pkg] + list(extra_pkgs or []) - logger.info("[install] npm install --prefix %s %s", staging, " ".join(install_targets)) - cmd = [npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets] - if not _run_installer("npm", pkg, cmd, timeout=300, env=with_hermes_node_path()): + cmd = [pm_bin, *_NODE_PM_ARGV[pm](str(staging)), *install_targets] + logger.info("[install] %s %s", pm, " ".join(cmd[1:])) + if not _run_installer(pm, pkg, cmd, timeout=300, env=with_hermes_node_path()): return None found = _first_existing(staging / "node_modules" / ".bin" / bin_name) if found is not None: - return _link_into_bin(found) - logger.warning("[install] npm install for %s succeeded but bin %s not found", pkg, bin_name) + # npm's Windows wrappers resolve their payload via ``%~dp0\..\``, so a copy or symlink + # in ``lsp/bin/`` points at nothing; use them where npm put them (``_existing_binary`` probes there). + return str(found) if _is_windows() and found.suffix.lower() in (".cmd", ".bat") else _link_into_bin(found) + logger.warning("[install] %s install for %s succeeded but bin %s not found", pm, pkg, bin_name) return None diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index 60021afab8..754ae82031 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -20,12 +20,13 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from agent.lsp import eventlog from agent.lsp.client import DIAGNOSTICS_DOCUMENT_WAIT, LSPClient, _diagnostic_key as _diag_key -from agent.lsp.servers import ServerContext, ServerDef, find_server_for_file, language_id_for +from agent.lsp.servers import SERVERS, ServerContext, ServerDef, custom_servers, find_server_for_file, language_id_for from agent.lsp.workspace import clear_cache, resolve_workspace_for_file logger = logging.getLogger("agent.lsp.manager") DEFAULT_IDLE_TIMEOUT = 600 # seconds; servers idle for >10min get reaped +_DELTA_BASELINE_CAP = 256 # per-file pre-write snapshots; paths never written again would otherwise live forever (#62950) MIN_IDLE_TIMEOUT = 30 # floor for config values; must exceed any per-op wait budget _Key = Tuple[str, str] @@ -103,6 +104,7 @@ class LSPService: init_overrides: Optional[Dict[str, Dict[str, Any]]] = None, disabled_servers: Optional[List[str]] = None, idle_timeout: float = DEFAULT_IDLE_TIMEOUT, + extra_servers: Optional[List[ServerDef]] = None, ) -> None: self._enabled = enabled self._wait_mode = wait_mode if wait_mode in {"document", "full"} else "document" @@ -113,6 +115,7 @@ class LSPService: self._init_overrides = init_overrides or {} self._disabled_servers = set(disabled_servers or []) self._idle_timeout = idle_timeout + self._extra_servers: List[ServerDef] = list(extra_servers or []) self._loop = _BackgroundLoop() if self._enabled: @@ -165,8 +168,18 @@ class LSPService: if isinstance(c.get("initialization_options"), dict)}, disabled_servers=[n for n, c in servers.items() if c.get("disabled")], idle_timeout=idle_timeout, + extra_servers=custom_servers(servers), ) + def _server_for(self, file_path: str) -> Optional[ServerDef]: + """Config-declared servers first (they may claim an extension ahead of a built-in), then the registry.""" + extra = find_server_for_file(file_path, self._extra_servers) if self._extra_servers else None + return extra or find_server_for_file(file_path) + + def handles_extension(self, ext: str) -> bool: + """True iff a config-declared or built-in server claims ``ext`` (pre-write capture decision).""" + return any(ext.lower() in s.extensions for s in (*self._extra_servers, *SERVERS)) + # ---- public API ---- def is_active(self) -> bool: @@ -190,7 +203,7 @@ class LSPService: def enabled_for(self, file_path: str) -> bool: """True iff LSP should run for this file: registered non-disabled server, git workspace, and pair not broken (a failed server costs nothing until ``hermes lsp restart`` / exit).""" - srv = find_server_for_file(file_path) if self._enabled else None + srv = self._server_for(file_path) if self._enabled else None if srv is None or srv.server_id in self._disabled_servers: return False key = self._broken_key(srv, file_path) @@ -202,14 +215,24 @@ class LSPService: if not self.enabled_for(file_path): return try: - # Outer budget must exceed the inner wait or a slow-but-alive server gets falsely marked broken. - t = max(8.0, self._wait_timeout + 3.0) + # Outer join budget must exceed the inner wait or a slow-but-alive server gets falsely + # marked broken; it is a ceiling only — the inner wait returns as soon as it completes. + t = max(DIAGNOSTICS_DOCUMENT_WAIT + 3.0, self._wait_timeout + 3.0) diags = self._loop.run(self._snapshot_async(file_path), timeout=t) except Exception as e: # noqa: BLE001 logger.debug("baseline snapshot failed for %s: %s", file_path, e) self._mark_broken_for_file(file_path, e) diags = [] - self._delta_baseline[os.path.abspath(file_path)] = diags or [] + self._set_delta_baseline(os.path.abspath(file_path), diags or []) + + def _set_delta_baseline(self, abs_path: str, diags: _Diags) -> None: + """Store a baseline, refreshing recency (pop + reinsert) so eviction tracks write order. + Callers run on arbitrary threads; the multi-step mutation needs the lock (callers don't hold it).""" + with self._state_lock: + self._delta_baseline.pop(abs_path, None) + self._delta_baseline[abs_path] = diags + while len(self._delta_baseline) > _DELTA_BASELINE_CAP: + del self._delta_baseline[next(iter(self._delta_baseline))] def get_diagnostics_sync( self, file_path: str, *, delta: bool = True, timeout: Optional[float] = None, @@ -224,7 +247,7 @@ class LSPService: """ if not self.enabled_for(file_path): return [] - server_id = find_server_for_file(file_path).server_id # enabled_for guarantees a match + server_id = self._server_for(file_path).server_id # enabled_for guarantees a match try: t = timeout if timeout is not None else self._wait_timeout + 2.0 diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) @@ -268,7 +291,7 @@ class LSPService: except Exception: # noqa: BLE001 fresh = [] if fresh: - self._delta_baseline[abs_path] = fresh + self._set_delta_baseline(abs_path, fresh) return diags def _mark_broken_for_file(self, file_path: str, exc: BaseException) -> None: @@ -276,7 +299,7 @@ class LSPService: The outer ``_loop.run`` timeout cancels the in-flight spawn before ``_get_or_spawn`` could record the failure; without this every later write would re-pay the full timeout. Also kills any half-initialized client and logs the failure once.""" - srv = find_server_for_file(file_path) + srv = self._server_for(file_path) key = self._broken_key(srv, file_path) if srv is not None else None if key is None: return @@ -332,17 +355,18 @@ class LSPService: """Open + wait for FRESH diagnostics: ``[]`` = checked clean, ``None`` = no verdict in budget. Callers must not substitute stale data for either. ``snapshot`` mode - (pre-write baseline) skips didSave and uses the default wait budget. + (pre-write baseline) skips didSave; both modes wait at most ``lsp.wait_timeout``. """ client = await self._get_or_spawn(file_path) if client is None: return None try: - version = await client.open_file(file_path, language_id=language_id_for(file_path)) + srv = self._server_for(file_path) + version = await client.open_file(file_path, language_id=language_id_for(file_path, srv)) if not snapshot: await client.save_file(file_path) fresh = await client.wait_for_diagnostics( - file_path, version, mode=self._wait_mode, timeout=None if snapshot else self._wait_timeout, + file_path, version, mode=self._wait_mode, timeout=self._wait_timeout, ) except Exception as e: # noqa: BLE001 if snapshot: @@ -355,7 +379,7 @@ class LSPService: async def _current_diags_async(self, file_path: str) -> _Diags: ws, gated = resolve_workspace_for_file(file_path) - srv = find_server_for_file(file_path) + srv = self._server_for(file_path) if not (ws and gated and srv): return [] # Same key _get_or_spawn() stored under: single-root servers live under their @@ -368,7 +392,7 @@ class LSPService: return list(client.diagnostics_for(file_path, fresh_only=True)) if client else [] async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]: - srv = find_server_for_file(file_path) + srv = self._server_for(file_path) if srv is None: return None if srv.server_id in self._disabled_servers: @@ -481,6 +505,66 @@ class LSPService: if clients: eventlog.log_reaped([(c.server_id, c.workspace_root) for c in clients], self._idle_timeout) await asyncio.gather(*(client.shutdown() for client in clients), return_exceptions=True) + # Externally deleted project roots (rm -rf, a worktree removed by another process) never go + # idle from the server's point of view — tsserver keeps its multi-GiB heap for a tree that is gone. + await self._detach_roots(lambda folder: not os.path.isdir(folder), reason="workspace root deleted") + + def release_workspace(self, workspace_root: str) -> int: + """Shut down the clients serving ``workspace_root`` or any root beneath it; returns how many. + + Called by the worktree cleanup paths BEFORE ``git worktree remove`` so a gateway that outlives the + session does not keep the language server (and its stdio pipes) alive for a tree that no longer + exists. Multi-root servers only drop the folder. Idempotent; best-effort — never blocks removal. + """ + if not self._enabled: + return 0 + root = os.path.abspath(workspace_root) + try: + return self._loop.run(self._release_async(root), timeout=15.0) + except Exception as e: # noqa: BLE001 + logger.debug("LSP release of %s failed: %s", root, e) + return 0 + + async def _release_async(self, root: str) -> int: + def _under(path: str) -> bool: + return path == root or path.startswith(root + os.sep) + + # A spawn in flight would insert its client AFTER we detach; let it land first (same loop, so + # once the future resolves the client is in ``_clients`` and the pass below sees it). + with self._state_lock: + pending = [fut for fut in self._spawning.values() if not fut.done()] + self._broken = {key for key in self._broken if not _under(key[1])} + for path in [p for p in self._delta_baseline if _under(p)]: + del self._delta_baseline[path] + if pending: + await asyncio.wait(pending, timeout=10.0) + released = await self._detach_roots(_under, reason="workspace released") + clear_cache() + return released + + async def _detach_roots(self, is_gone: Callable[[str], bool], *, reason: str) -> int: + """Shared teardown primitive for :meth:`release_workspace` and the reaper. + + Clients whose every workspace folder ``is_gone`` are detached under ``_state_lock`` and shut down; + multi-root clients that still serve other folders only drop the gone ones. Returns the number of + clients shut down. + """ + with self._state_lock: + dead_keys = [key for key, c in self._clients.items() if all(map(is_gone, c.workspace_folders))] + clients = [self._clients.pop(key) for key in dead_keys] + for key in dead_keys: + self._last_used.pop(key, None) + trims = [(c, [f for f in c.workspace_folders if is_gone(f)]) for c in self._clients.values()] + for client, folders in trims: + for folder in folders: + try: + await client.remove_workspace_folder(folder) + except Exception as e: # noqa: BLE001 + logger.debug("LSP folder removal for %s failed: %s", folder, e) + if clients: + eventlog.log_released([(c.server_id, c.workspace_root) for c in clients], reason) + await asyncio.gather(*(client.shutdown() for client in clients), return_exceptions=True) + return len(clients) async def _shutdown_async(self) -> None: if (reaper := self._idle_reaper_task) is not None: diff --git a/agent/lsp/servers.py b/agent/lsp/servers.py index 1bb93d8f75..32dc3b134a 100644 --- a/agent/lsp/servers.py +++ b/agent/lsp/servers.py @@ -34,7 +34,7 @@ _EXTS_BY_LANGUAGE: Dict[str, Sequence[str]] = { "csharp": (".cs", ".csx"), "fsharp": (".fs", ".fsi", ".fsx"), "swift": (".swift",), "java": (".java",), "kotlin": (".kt", ".kts"), "yaml": (".yaml", ".yml"), "json": (".json",), "jsonc": (".jsonc",), - "lua": (".lua",), "php": (".php",), "prisma": (".prisma",), "dart": (".dart",), + "lua": (".lua",), "php": (".php",), "blade": (".blade.php",), "prisma": (".prisma",), "dart": (".dart",), "ocaml": (".ml", ".mli"), "shellscript": (".sh", ".bash", ".zsh"), "terraform": (".tf", ".tfvars"), @@ -75,6 +75,8 @@ class ServerDef: # Server handles ``workspace/didChangeWorkspaceFolders``: one process serves every project root # (git worktrees included) as extra workspaceFolders instead of one process per root. multi_root: bool = False + # didOpen languageId; "" = derive from LANGUAGE_BY_EXT (custom servers name theirs in config). + language_id: str = "" def matches(self, file_path: str) -> bool: return _file_ext_or_basename(file_path) in self.extensions @@ -92,10 +94,16 @@ class ServerContext: # ---- helpers ---- +# Multi-part extensions that name a different language than their last segment: ``os.path.splitext`` +# would reduce ``home.blade.php`` to ``.php`` and hand Blade templates to the plain-PHP server. +_COMPOUND_EXTS = (".blade.php",) + + def _file_ext_or_basename(path: str) -> str: - """Lower-cased extension, or the full basename for extensionless files (``Dockerfile``).""" + """Lower-cased extension (compound ones first), or the full basename for extensionless files (``Dockerfile``).""" base = os.path.basename(path) - return os.path.splitext(base)[1].lower() or base + lower = base.lower() + return next((c for c in _COMPOUND_EXTS if lower.endswith(c)), None) or os.path.splitext(base)[1].lower() or base def _which(*names: str) -> Optional[str]: @@ -158,9 +166,11 @@ def _spawn_pyright(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: bin_path = _find_binary(ctx, "pyright", ("pyright-langserver", "pyright"), "pyright") if bin_path is None: return None - # If we got the cli ``pyright``, the langserver is its sibling. - if os.path.basename(bin_path) in {"pyright", "pyright.exe"}: - sibling = os.path.join(os.path.dirname(bin_path), "pyright-langserver") + # If we got the cli ``pyright``, the langserver is its sibling — same suffix, since on Windows + # the bare sibling is npm's unrunnable POSIX shim. + stem, suffix = os.path.splitext(os.path.basename(bin_path)) + if stem == "pyright": + sibling = os.path.join(os.path.dirname(bin_path), f"pyright-langserver{suffix}") if os.path.exists(sibling): bin_path = sibling # Point pyright at the project venv; its default "python on PATH" rarely is. @@ -203,6 +213,67 @@ def _spawn_bash_ls(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: return _make_spec(root, ctx, "bash-language-server", [bin_path, "start"]) +_VUE_REINSTALL = ( + "delete /lsp/node_modules/@vue and /lsp/bin/vue-language-server*, " + "then run: hermes lsp install vue-language-server" +) +_VUE_TUNNEL_MSG = ( + "vue-language-server: the installed @vue/language-server is 3.x, which only works behind a client-hosted " + f"tsserver tunnel Hermes does not run — no diagnostics will arrive. Reinstall the self-hosting 2.x line: {_VUE_REINSTALL}" +) +_VUE_TSDK_MSG = ( + "vue-language-server: no JavaScript TypeScript SDK (typescript/lib/typescript.js) next to the server or under " + f"the project's node_modules — diagnostics are skipped. Reinstall (the recipe co-installs one): {_VUE_REINSTALL}" +) + + +def _node_modules_trees(bin_path: str, root: str) -> List[str]: + """``node_modules`` trees that may hold the Vue server and its TypeScript SDK: + the launcher's own tree (symlinks resolved), Hermes staging, then the project's.""" + from agent.lsp.install import hermes_lsp_bin_dir + trees = [str(hermes_lsp_bin_dir().parent / "node_modules"), os.path.join(root, "node_modules")] + real = os.path.realpath(bin_path) + marker = f"{os.sep}node_modules{os.sep}" + if (idx := real.rfind(marker)) >= 0: + trees.insert(0, real[: idx + len(marker) - 1]) + return trees + + +def _vue_server_major(trees: Sequence[str]) -> int: + """Major version of the first ``@vue/language-server`` found in ``trees``; 0 when unreadable.""" + import json + for tree in trees: + try: + with open(os.path.join(tree, "@vue", "language-server", "package.json"), encoding="utf-8") as fh: + return int(str(json.load(fh).get("version", "")).split(".")[0]) + except (OSError, ValueError): + continue + return 0 + + +def _typescript_sdk_dir(trees: Sequence[str]) -> Optional[str]: + """First ``typescript/lib`` in ``trees`` holding a JS ``typescript.js`` (TypeScript 7+ ships none).""" + cands = (os.path.join(tree, "typescript", "lib") for tree in trees) + return next((c for c in cands if os.path.isfile(os.path.join(c, "typescript.js"))), None) + + +def _spawn_vue(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: + """Spawn @vue/language-server 2.x self-hosting TypeScript (``hybridMode`` off, explicit ``tsdk``).""" + bin_path = _find_binary(ctx, "vue-language-server", ("vue-language-server",), "@vue/language-server") + if bin_path is None: + return None + trees = _node_modules_trees(bin_path, root) + if _vue_server_major(trees) >= 3: + _warn_once("vue-tunnel", _VUE_TUNNEL_MSG) + return None + tsdk = _typescript_sdk_dir(trees) + if tsdk is None: + _warn_once("vue-tsdk", _VUE_TSDK_MSG) + return None + return _make_spec(root, ctx, "vue-language-server", [bin_path, "--stdio"], + {"typescript": {"tsdk": tsdk}, "vue": {"hybridMode": False}}) + + def _find_pses_bundle(ctx: ServerContext) -> Optional[str]: """Locate the PowerShellEditorServices bundle dir (release zip, manual install). Resolution order: ``lsp.servers.powershell.command[0]`` when a directory, ``init_overrides["powershell"]["bundlePath"]``, @@ -296,7 +367,7 @@ SERVERS: List[ServerDef] = [ "JavaScript/TypeScript — typescript-language-server", resolve_root=_root_typescript, which=("typescript-language-server",), args=("--stdio",), install_pkg="typescript-language-server", seed=True), _server("vue-language-server", (".vue",), "Vue.js — @vue/language-server", resolve_root=_root_typescript, - args=("--stdio",), install_pkg="@vue/language-server"), + build_spawn=_spawn_vue), _server("svelte-language-server", (".svelte",), "Svelte — svelte-language-server", resolve_root=_root_typescript, which=("svelteserver", "svelte-language-server"), args=("--stdio",), install_pkg="svelte-language-server"), _server("astro-language-server", (".astro",), "Astro — @astrojs/language-server", resolve_root=_root_typescript, @@ -312,6 +383,9 @@ SERVERS: List[ServerDef] = [ _server("lua-language-server", (".lua",), "Lua — lua-language-server", markers=[".luarc.json", ".luarc.jsonc", ".luacheckrc", ".stylua.toml", "stylua.toml", "selene.toml", "selene.yml"], install_pkg="lua-language-server"), + # Before intelephense: Blade templates are Laravel's, plain .php stays with intelephense. + _server("laravel-lsp", (".blade.php",), "Laravel Blade — laravel-lsp (manual: composer global require laravel/lsp)", + markers=["artisan", "composer.json", "composer.lock"], args=("lsp",)), _server("intelephense", (".php",), "PHP — intelephense", markers=["composer.json", "composer.lock", ".php-version"], args=("--stdio",), install_pkg="intelephense", base_init={"telemetry": {"enabled": False}}), _server("ocaml-lsp", (".ml", ".mli"), "OCaml — ocaml-lsp", markers=["dune-project", "dune-workspace", ".merlin", "opam"], @@ -348,14 +422,54 @@ SERVERS: List[ServerDef] = [ ] -def find_server_for_file(file_path: str) -> Optional[ServerDef]: - """Return the registry entry that handles ``file_path``, or None.""" - return next((srv for srv in SERVERS if srv.matches(file_path)), None) +def find_server_for_file(file_path: str, servers: Optional[Sequence[ServerDef]] = None) -> Optional[ServerDef]: + """Return the first entry of ``servers`` (default: the built-in registry) that handles ``file_path``.""" + return next((srv for srv in (SERVERS if servers is None else servers) if srv.matches(file_path)), None) -def language_id_for(path: str) -> str: - """Return the LSP languageId to send in didOpen for ``path``.""" +def language_id_for(path: str, srv: Optional[ServerDef] = None) -> str: + """Return the LSP languageId to send in didOpen for ``path`` (a custom server's own id wins).""" + if srv is not None and srv.language_id: + return srv.language_id return LANGUAGE_BY_EXT.get(_file_ext_or_basename(path), "plaintext") -__all__ = ["ServerDef", "ServerContext", "SpawnSpec", "SERVERS", "find_server_for_file", "language_id_for", "LANGUAGE_BY_EXT"] +def _custom_spawn(server_id: str, command: Sequence[str]) -> _SpawnFn: + """Spawn builder for a config-declared server: ``command[0]`` is a path or a PATH lookup, no auto-install.""" + def build(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: + bin_path = _which(os.path.expanduser(command[0])) + if bin_path is None: + _warn_once(f"custom:{server_id}", f"lsp.servers.{server_id}: command {command[0]!r} not found on PATH — server skipped") + return None + return _make_spec(root, ctx, server_id, [bin_path, *command[1:]]) + return build + + +def custom_servers(servers_cfg: Any) -> List[ServerDef]: + """``lsp.servers`` entries that declare ``extensions`` and name no built-in server are user-declared + servers (issue #100257). They go AHEAD of the built-ins so a custom entry can claim an extension; + malformed entries are logged and skipped so one typo never disables the rest of the subsystem.""" + if not isinstance(servers_cfg, dict): + return [] + builtin = {s.server_id for s in SERVERS} + out: List[ServerDef] = [] + for server_id, cfg in servers_cfg.items(): + if server_id in builtin or not isinstance(cfg, dict) or "extensions" not in cfg: + continue + command, exts, markers = cfg.get("command"), cfg.get("extensions"), cfg.get("root_markers") + if not (isinstance(command, list) and command and all(isinstance(c, str) and c for c in command) + and isinstance(exts, list) and exts and all(isinstance(e, str) and e for e in exts)): + logger.warning("lsp.servers.%s: custom server needs command: [bin, ...args] and extensions: [.ext, ...] — ignored", server_id) + continue + out.append(ServerDef( + str(server_id), tuple(e.lower() if e.startswith(".") else e for e in exts), + _markers_root([str(m) for m in markers] if isinstance(markers, list) and markers else None), + _custom_spawn(str(server_id), command), + description=str(cfg.get("description") or f"{server_id} — custom (lsp.servers)"), + language_id=str(cfg.get("language_id") or ""), + )) + return out + + +__all__ = ["ServerDef", "ServerContext", "SpawnSpec", "SERVERS", "custom_servers", "find_server_for_file", + "language_id_for", "LANGUAGE_BY_EXT"] diff --git a/agent/lsp/workspace.py b/agent/lsp/workspace.py index 799798cae6..b1115d5ec9 100644 --- a/agent/lsp/workspace.py +++ b/agent/lsp/workspace.py @@ -15,8 +15,10 @@ from typing import Iterable, Iterator, Optional, Tuple logger = logging.getLogger("agent.lsp.workspace") -# Cache: start dir → (worktree_root, is_git) so repeated calls don't re-stat. Cleared on shutdown. +# Cache: start dir → (worktree_root, is_git) so repeated calls don't re-stat. Cleared on shutdown; capped +# because every distinct file dir a long gateway session touches lands here (#62950). _workspace_cache: dict = {} +_WORKSPACE_CACHE_CAP = 512 # Walk cap: the deepest reasonable monorepo is well under 64 levels; bounds a # pathological cwd or symlink cycle even though parent-equality normally stops us. @@ -60,16 +62,18 @@ def find_git_worktree(start: str) -> Optional[str]: cached = _workspace_cache.get(str(start_path)) if cached is not None: return cached[0] + resolved = None for cur in _walk_up(start_path): try: if (cur / ".git").exists(): resolved = str(cur) - _workspace_cache[str(start_path)] = (resolved, True) - return resolved + break except OSError: break # permission error on a parent dir — bail out cleanly - _workspace_cache[str(start_path)] = (None, False) - return None + _workspace_cache[str(start_path)] = (resolved, resolved is not None) + if len(_workspace_cache) > _WORKSPACE_CACHE_CAP: + _workspace_cache.clear() # a stat cache: resetting is a few re-stats, and one atomic op is thread-safe + return resolved def is_inside_workspace(path: str, workspace_root: str) -> bool: @@ -133,7 +137,14 @@ def resolve_workspace_for_file(file_path: str, *, cwd: Optional[str] = None) -> """Return ``(workspace_root, gated_in)`` for a file. The cwd's worktree wins when the file is inside it; otherwise the file's own worktree is the fallback anchor (monorepos / unrelated checkouts). ``(None, False)`` when neither is in a git worktree.""" - cwd_root = find_git_worktree(cwd or os.getcwd()) + try: + cwd_anchor = cwd or os.getcwd() + except OSError: + # The process cwd was removed underneath us (a scratch workspace cleaned up at + # card completion); getcwd keeps raising even after the path is recreated, so + # there is simply no cwd anchor — fall through to the file's own worktree. + cwd_anchor = None + cwd_root = find_git_worktree(cwd_anchor) if cwd_anchor else None if cwd_root is not None and is_inside_workspace(file_path, cwd_root): return cwd_root, True file_root = find_git_worktree(file_path) diff --git a/agent/turn_final_response.py b/agent/turn_final_response.py index ecda17c965..af36bc717f 100644 --- a/agent/turn_final_response.py +++ b/agent/turn_final_response.py @@ -56,7 +56,8 @@ def finish_text_response( iteration-limit summarization; the final message is appended and flushed only after the stop gates accept it.""" from agent.conversation_loop import ( - _CODEX_ACK_CONTINUATION_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT, _join_truncated_parts + _CODEX_ACK_CONTINUATION_NUDGE, _DEGENERATE_FINAL_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT, + _join_truncated_parts ) def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> FinalResponseVerdict: @@ -142,7 +143,8 @@ def finish_text_response( # delivery channel (gateway status message / CLI print). NEVER appended to messages/api_messages: # conversation context and the cached prompt prefix stay byte-identical. from agent.agent_runtime_helpers import ( - intent_ack_continuation_mode, promoted_reasoning_announces_action, trailing_continue_intent + intent_ack_continuation_mode, looks_like_degenerate_final, promoted_reasoning_announces_action, + tool_results_this_turn, trailing_continue_intent, ) _ack_mode = intent_ack_continuation_mode(agent) @@ -162,7 +164,23 @@ def finish_text_response( or (bool(_promoted) and promoted_reasoning_announces_action(_stall_text)) ) ) - if _stall_continue_intent or ( + # Degenerate-final guard (#103483): the turn did real tool work and then stopped on a + # fragment. Same scope knob and the SAME bounded counter as the ack continuation; the nudge + # row itself closes the tool-work window, so a second fragment ends the turn as the answer. + _tool_rows = tool_results_this_turn(messages) + _degenerate_final = ( + bool(getattr(agent, "_stall_guards", True)) + and _ack_mode != "off" + and codex_ack_continuations < 2 + and _tool_rows > 0 + and looks_like_degenerate_final(_stall_text, user_message=user_message) + ) + # Precedence: an announced next action outranks the fragment shape; the codex ack is last. + if _stall_continue_intent: + _continuation_kind = "stall" + elif _degenerate_final: + _continuation_kind = "degenerate" + elif ( _ack_mode != "off" and agent.valid_tool_names and codex_ack_continuations < 2 @@ -171,12 +189,22 @@ def finish_text_response( require_workspace=(_ack_mode == "codex_only"), ) ): - if _stall_continue_intent: + _continuation_kind = "ack" + else: + _continuation_kind = None + if _continuation_kind: + if _continuation_kind == "stall": logger.info( "Stall guard: turn ending on trailing continue-" "intent with no tool calls — re-prompting to act " "(%d/2)", codex_ack_continuations + 1, ) + elif _continuation_kind == "degenerate": + logger.warning( + "Degenerate final: %d-char fragment %r ended the turn after %d tool result(s) — " + "re-prompting (%d/2)", len(_stall_text), _stall_text[:40], _tool_rows, + codex_ack_continuations + 1, + ) codex_ack_continuations += 1 interim_msg = agent._build_assistant_message(assistant_message, "incomplete") if _promoted: @@ -185,7 +213,13 @@ def finish_text_response( interim_msg["api_content"] = final_response append_message(messages, interim_msg) agent._emit_interim_assistant_message(interim_msg) - append_message(messages, {"role": "user", "content": _CODEX_ACK_CONTINUATION_NUDGE}) + append_message(messages, { + "role": "user", + "content": ( + _DEGENERATE_FINAL_NUDGE if _continuation_kind == "degenerate" + else _CODEX_ACK_CONTINUATION_NUDGE + ), + }) agent._session_messages = messages # An acknowledgment is non-final: its text must not suppress iteration-limit # summarization if the continuation exhausts budget. diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index b00c4eadc7..82c6aeb667 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -232,8 +232,7 @@ blurred backdrop. Empty lists hide their search field. - **`SegmentedControl`** — the choice control for small mutually-exclusive sets (color mode, tool-call display, usage period). Replaces radio piles and - pill rows. `iconOnly` renders compact icon buttons with label tooltips and - accessible names; use `codiconIcon()` for Codicon options. + pill rows. - **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper. - **`FanMenu`** (`src/components/ui/fan-menu.tsx`) — one hub control that fans sibling toggles out on hover: `direction` `vertical` | `horizontal` diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8aafaf431f..109d8f7370 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -6128,7 +6128,7 @@ async function showPluginCompatNoticeOnce() { }) if (response === 0) { - handleDeepLink(`${HERMES_PROTOCOL}://open/skills?tab=plugins`) + handleDeepLink(`${HERMES_PROTOCOL}://open/capabilities?tab=plugins`) } } finally { try { diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts index 3c55f20fcd..4ec63e9376 100644 --- a/apps/desktop/src/api/client.ts +++ b/apps/desktop/src/api/client.ts @@ -147,7 +147,7 @@ export function hermesApi(request: HermesApiRequest): Promise { // // A profile is not a machine-global name — it belongs to ONE gateway. The // Capabilities surface can be pointed at any (connection, profile) pair -// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so +// (CapabilitiesView's scope selector, Bot Mode's fixedProfile/fixedConnection), so // its REST helpers accept either the legacy string form or an explicit scope // object: // diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/capabilities/index.test.tsx similarity index 57% rename from apps/desktop/src/app/skills/index.test.tsx rename to apps/desktop/src/app/capabilities/index.test.tsx index a99f14dd13..e397d1e849 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/capabilities/index.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { QueryClientProvider } from '@tanstack/react-query' -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router' import type * as ReactRouterDom from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,10 +9,6 @@ import type * as HermesApi from '@/hermes' import { queryClient } from '@/lib/query-client' import type * as HubActions from '@/store/hub-actions' -import { parseCatalog } from './catalog-data' -import { SkillCatalog } from './skill-catalog' -import { $catalogCardView } from './store' - const getSkills = vi.fn() const getToolsets = vi.fn() const setSkillEnabled = vi.fn() @@ -22,8 +18,9 @@ const selectToolsetProvider = vi.fn() const getUsageAnalytics = vi.fn() const getProfiles = vi.fn() const getSkillContent = vi.fn() +const getOfficialSkills = vi.fn() -// Partial mock: keep the real module (SkillsView pulls in @/store/profile, +// Partial mock: keep the real module (CapabilitiesView pulls in @/store/profile, // whose import-time subscription calls setApiRequestProfile) and stub only the // calls we assert on. Args are forwarded so the per-profile scope arg is // observable. @@ -38,7 +35,8 @@ vi.mock('@/hermes', async importOriginal => ({ selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider), getUsageAnalytics: (days: number, profile?: null | string) => getUsageAnalytics(days, profile), getProfiles: () => getProfiles(), - getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile) + getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile), + getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile) })) // Notifications hit nanostores/timers we don't care about here. @@ -48,7 +46,7 @@ vi.mock('@/store/notifications', () => ({ })) // The catalog Install button routes through the hub action pipeline — stub the -// action entrypoint (real module kept: SkillsView reads $hubActions and the +// action entrypoint (real module kept: CapabilitiesView reads $hubActions and the // query keys from it). vi.mock('@/store/hub-actions', async importOriginal => ({ ...(await importOriginal()), @@ -67,7 +65,7 @@ vi.mock('react-router', async importOriginal => ({ // Import at module scope (after the hoisted vi.mock calls) so the heavy // component-tree transform is paid during collection, not billed against the // first test's testTimeout — same flake class as messaging/index.test.tsx. -const { SkillsView } = await import('./index') +const { CapabilitiesView } = await import('./index') function toolset(overrides: Record = {}) { return { @@ -86,10 +84,10 @@ async function renderSkills() { let result: ReturnType await act(async () => { result = render( - // SkillsView reads skills/toolsets via useQuery, so it needs a provider. + // CapabilitiesView reads skills/toolsets via useQuery, so it needs a provider. - - + + ) @@ -99,13 +97,12 @@ async function renderSkills() { } beforeEach(() => { - // Scope/install cases exercise the retained list layout; cards have dedicated coverage. - $catalogCardView.set(false) getSkills.mockResolvedValue([]) getToolsets.mockResolvedValue([toolset()]) setToolsetEnabled.mockResolvedValue({ ok: true, name: 'web', enabled: false }) getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] }) getUsageAnalytics.mockResolvedValue({ tools: [] }) + getOfficialSkills.mockResolvedValue({ skills: [] }) getSkillContent.mockResolvedValue({ name: 'web-research', path: '/skills/web-research/SKILL.md', @@ -119,17 +116,16 @@ beforeEach(() => { afterEach(() => { cleanup() vi.clearAllMocks() - vi.unstubAllGlobals() // Shared singleton client — drop cached skills/toolsets so each test refetches. queryClient.clear() }) -// SkillsView is a heavy module (import cost now paid at module scope above, +// CapabilitiesView is a heavy module (import cost now paid at module scope above, // during collection) but the file still legitimately runs ~14s on CI runners — // right against the global 15s per-test budget, so slow runners cascade-fail // all 11 tests (2× in a row on PR #93612, plus a main run the same hour). // Give this file headroom; the tests are not slow individually. -describe('SkillsView toolset management', { timeout: 60_000 }, () => { +describe('CapabilitiesView toolset management', { timeout: 60_000 }, () => { it('renders a switch for each toolset and toggles it off', async () => { await renderSkills() @@ -184,8 +180,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { await act(async () => { render( - - + + ) @@ -229,8 +225,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { await act(async () => { render( - - + + ) @@ -272,8 +268,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { await act(async () => { render( - - + + ) @@ -288,190 +284,65 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { expect(await screen.findByText(/Deep research steps/)).toBeTruthy() }) - it.each(['web-research', 'official/research/web-research'])( - 'disables catalog installation when the installed list contains %s', - async installedName => { - const { installHubSkill } = await import('@/store/hub-actions') - queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [{ - name: 'web-research', - identifier: 'official/research/web-research', - source: 'official', - category: 'research', - description: 'Research the web' - }])) + it('hub picker refuses to reinstall an already-installed skill', async () => { + const { notify } = await import('@/store/notifications') + const { EmbeddedHubPicker } = await import('./skills/embedded-hub-picker') - render( - - - - ) + render() - fireEvent.click(screen.getByRole('button', { name: /^web-research/ })) - const installed = screen.getByRole('button', { name: 'Installed' }) - expect((installed as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(installed) - expect(installHubSkill).not.toHaveBeenCalled() - } - ) - - it.each([ - { - source: 'github', - identifier: 'github:example/skills/research/community-research', - expectedIdentifier: 'github:example/skills/research/community-research' - }, - { - source: 'clawhub', - identifier: 'community-research', - expectedIdentifier: 'clawhub/community-research' - }, - { - source: 'clawhub', - identifier: 'clawhub/community-research', - expectedIdentifier: 'clawhub/community-research' - } - ])('installs $identifier with its source-qualified target in the pinned connection and profile', async ({ source, identifier, expectedIdentifier }) => { - const { installHubSkill } = await import('@/store/hub-actions') - const entry = { - name: 'community-research', - identifier, - source, - category: 'research', - description: 'Community research workflow' - } - queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [ - { ...entry, name: 'other-skill', source: 'github', identifier: 'github:example/skills/other-skill' }, - entry - ])) + // The picker is expanded by default — the hub iframe is live on mount. + expect(document.querySelector('iframe')).toBeTruthy() await act(async () => { - render( - - - - - + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'hermes-skill-pick', name: 'web-research', identifier: 'web-research' }, + origin: 'https://hermes-agent.nousresearch.com' + }) ) }) - fireEvent.click(screen.getByRole('button', { name: 'Browse' })) - fireEvent.click(await screen.findByRole('button', { name: /^community-research/ })) - expect(screen.getByRole('heading', { name: entry.name })).toBeTruthy() - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Install' })) - }) - - expect(installHubSkill).toHaveBeenCalledExactlyOnceWith(expectedIdentifier, { - connectionId: 'homelab', profile: 'researcher' - }) - }) - - it('keeps a pending install tied to its entry and scope when the pinned target changes', async () => { - const { installHubSkill } = await import('@/store/hub-actions') - const identifier = 'clawhub/community-research' - queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [ - { name: 'community-research', identifier: 'community-research', source: 'clawhub' }, - { name: 'other-skill', identifier: 'official/research/other-skill', source: 'optional' } - ])) - let finishFirst!: () => void - let finishSecond!: () => void - const firstInstall = new Promise(resolve => { finishFirst = resolve }) - const secondInstall = new Promise(resolve => { finishSecond = resolve }) - vi.mocked(installHubSkill) - .mockReturnValueOnce(firstInstall) - .mockReturnValueOnce(secondInstall) - - const scopedView = (connectionId: string, profile: string) => ( - - - - - + // Refused with an informational toast, no install action spawned. + await waitFor(() => + expect(vi.mocked(notify)).toHaveBeenCalledWith( + expect.objectContaining({ title: '"web-research" is already installed' }) + ) ) - const view = render(scopedView('homelab', 'researcher')) - fireEvent.click(screen.getByRole('button', { name: 'Browse' })) - fireEvent.click(await screen.findByRole('button', { name: /^community-research/ })) - fireEvent.click(screen.getByRole('button', { name: 'Install' })) - const pending = screen.getByRole('button', { name: 'Installing...' }) - expect(pending.disabled).toBe(true) - expect(pending.querySelector('svg.animate-spin')).not.toBeNull() - fireEvent.click(pending) - expect(installHubSkill).toHaveBeenCalledExactlyOnceWith(identifier, { - connectionId: 'homelab', profile: 'researcher' - }) - - fireEvent.click(screen.getByRole('button', { name: /^other-skill/ })) - expect(screen.getByRole('button', { name: 'Install' }).disabled).toBe(false) - fireEvent.click(screen.getByRole('button', { name: /^community-research/ })) - expect(screen.getByRole('button', { name: 'Installing...' }).disabled).toBe(true) - - view.rerender(scopedView('other-gateway', 'writer')) - fireEvent.click(await screen.findByRole('button', { name: /^community-research/ })) - const nextInstall = screen.getByRole('button', { name: 'Install' }) - expect(nextInstall.disabled).toBe(false) - fireEvent.click(nextInstall) - expect(installHubSkill).toHaveBeenNthCalledWith(2, identifier, { - connectionId: 'other-gateway', profile: 'writer' - }) - - await act(async () => { - finishFirst() - await firstInstall - }) - expect(screen.getByRole('button', { name: 'Installing...' }).disabled).toBe(true) - await act(async () => { - finishSecond() - await secondInstall - }) - expect(screen.getByRole('button', { name: 'Install' }).disabled).toBe(false) - expect(installHubSkill).toHaveBeenCalledTimes(2) }) - it('loads the public catalog only on Browse and reuses it across skills and tools tab switches', async () => { - const fetchCatalog = vi.fn().mockResolvedValue({ - ok: true, - json: async () => [{ - name: 'catalog-research', - identifier: 'official/research/catalog-research', - source: 'official', - category: 'research', - description: 'Research from the public snapshot' - }] - }) - vi.stubGlobal('fetch', fetchCatalog) - + it('mounts the hub iframe lazily and keeps it (hidden) across tab switches', async () => { + // On a non-Skills tab the docs-site iframe must not exist at all — an + // eagerly mounted hub is exactly the Capabilities lag bug. await renderSkills() // ?tab=toolsets await screen.findByRole('switch', { name: 'Turn Web Search toolset off' }) - expect(fetchCatalog).not.toHaveBeenCalled() + expect(document.querySelector('iframe')).toBeNull() cleanup() + // Embedded mode drives tabs through local state (the route hooks are + // mocked here), starting on Skills: the picker mounts with the tab. await act(async () => { render( - - + + ) }) - expect(screen.queryByRole('heading', { name: 'catalog-research' })).toBeNull() - expect(fetchCatalog).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Browse' })) - expect(await screen.findByRole('heading', { name: 'catalog-research' })).toBeTruthy() - expect(fetchCatalog).toHaveBeenCalledTimes(1) - expect(fetchCatalog.mock.calls[0][0]).toMatch(/\/docs\/api\/skills\.json$/) + const iframe = document.querySelector('iframe') + expect(iframe).toBeTruthy() + expect(iframe!.closest('section')!.classList.contains('hidden')).toBe(false) - fireEvent.click(screen.getByRole('button', { name: 'Installed' })) - expect(screen.queryByRole('heading', { name: 'catalog-research' })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Browse' })) - expect(await screen.findByRole('heading', { name: 'catalog-research' })).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /^Tools/ })) - await screen.findByRole('switch', { name: 'Turn Web Search toolset off' }) - expect(screen.queryByRole('heading', { name: 'catalog-research' })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /^Skills/ })) - expect(await screen.findByRole('heading', { name: 'catalog-research' })).toBeTruthy() - expect(fetchCatalog).toHaveBeenCalledTimes(1) + // Switch to Tools → the iframe STAYS mounted (no docs-site reload on the + // next visit) but its section is fully hidden, so nothing from the hub + // can paint over the toolsets UI. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Tools/ })) + }) + const kept = document.querySelector('iframe') + expect(kept).toBeTruthy() + expect(kept!.closest('section')!.classList.contains('hidden')).toBe(true) }) it('shows a vision explainer that deep-links to Settings → Models', async () => { @@ -510,8 +381,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { await act(async () => { render( - - + + ) @@ -574,8 +445,13 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { } }) - it('offers optional skills through Browse and guards installed skills using the scoped installed list', async () => { + it('lists the built-in optional-skills catalog with Install buttons that route through the hub pipeline', async () => { + // The full official catalog renders BELOW the installed list; each row + // carries an Install button (no toggle until installed) that routes + // through the standard hub action pipeline scoped to the Capabilities + // profile. Already-installed catalog entries are filtered out. const { installHubSkill } = await import('@/store/hub-actions') + getSkills.mockResolvedValue([ { name: 'web-research', @@ -586,54 +462,62 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { provenance: 'bundled' } ]) - queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [ - { - name: 'gif-search', - description: 'Search GIFs', - source: 'optional', - installIdentifier: 'official/gifs/gif-search', - category: 'gifs', - tags: ['gifs'] - }, - { - name: 'web-research', - description: 'Research the web', - source: 'optional', - identifier: 'official/research/web-research', - category: 'research' - } - ])) + getOfficialSkills.mockResolvedValue({ + skills: [ + { + name: 'gif-search', + description: 'Search GIFs', + identifier: 'official/gifs/gif-search', + category: 'gifs', + installed: false, + tags: ['gifs'] + }, + { + name: 'web-research', + description: 'already here under a different source', + identifier: 'official/research/web-research', + category: 'research', + installed: false, + tags: [] + }, + { + name: 'ascii-art', + description: 'ASCII art', + identifier: 'official/creative/ascii-art', + category: 'creative', + installed: true, + tags: [] + } + ] + }) await act(async () => { render( - - + + ) }) - expect(await screen.findByRole('switch', { name: 'web-research' })).toBeTruthy() - expect(screen.queryByRole('button', { name: /^gif-search/ })).toBeNull() - expect(screen.queryByRole('button', { name: 'Install' })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Browse' })) - fireEvent.click(await screen.findByRole('button', { name: /^web-research/ })) + // Catalog section header + the one genuinely-available row. Rows already + // installed (lock flag OR name collision with the installed list) are gone. + expect(await screen.findByText('Available to install')).toBeTruthy() + expect(await screen.findByText('gif-search')).toBeTruthy() + expect(screen.queryByText('ascii-art')).toBeNull() - // Browse keeps installed entries discoverable, but never reinstallable. - const detail = within(screen.getByRole('main')) - const installed = detail.getByRole('button', { name: 'Installed' }) - expect(installed.disabled).toBe(true) - fireEvent.click(installed) - expect(installHubSkill).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: /^gif-search/ })) - expect(detail.getByRole('heading', { name: 'gif-search' })).toBeTruthy() - expect(screen.queryByRole('switch')).toBeNull() + // The installed skill still shows its toggle; the catalog row shows + // Install instead of a switch. + expect(screen.getByRole('switch', { name: 'web-research' })).toBeTruthy() + const install = screen.getByRole('button', { name: 'Install' }) await act(async () => { - fireEvent.click(detail.getByRole('button', { name: 'Install' })) + fireEvent.click(install) }) - expect(installHubSkill).toHaveBeenCalledExactlyOnceWith('official/gifs/gif-search', 'researcher') + await waitFor(() => + expect(vi.mocked(installHubSkill)).toHaveBeenCalledWith('official/gifs/gif-search', expect.anything()) + ) }) }) diff --git a/apps/desktop/src/app/capabilities/index.tsx b/apps/desktop/src/app/capabilities/index.tsx new file mode 100644 index 0000000000..71105f4b81 --- /dev/null +++ b/apps/desktop/src/app/capabilities/index.tsx @@ -0,0 +1,217 @@ +import type * as React from 'react' +import { useCallback, useMemo, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { useI18n } from '@/i18n' +import { queryClient } from '@/lib/query-client' +import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' +import { useStoreSelector } from '@/lib/use-session-slice' +import { $gateway } from '@/store/gateway' +import { OFFICIAL_SKILLS_KEY } from '@/store/hub-actions' + +import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' +import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { PanelEmpty } from '../overlays/panel' +import { PageSearchShell } from '../page-search-shell' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' + +import { McpTab } from './mcp/mcp-tab' +import { PluginsTab } from './plugins/plugins-tab' +import { CapabilityScopeSelector, useCapabilityScope } from './scope-selector' +import { EmbeddedHubPicker } from './skills/embedded-hub-picker' +import { SKILLS_QUERY_KEY, skillSearchTerms, useSkillsQuery } from './skills/skills-data' +import { SkillsTab } from './skills/skills-tab' +import { refreshToolCalls } from './toolsets/tool-calls' +import { TOOLSETS_QUERY_KEY, toolsetSearchTerms, useToolsetsQuery, visibleToolsetCount } from './toolsets/toolsets-data' +import { ToolsetsTab } from './toolsets/toolsets-tab' + +// Skills Hub browsing lives inside the Skills tab. Legacy `?tab=hub` +// links fall back to 'skills' via useRouteEnumParam. +const CAPABILITY_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const + +type CapabilityMode = (typeof CAPABILITY_MODES)[number] + +interface CapabilitiesViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup + /** Embedded mode (plugin dialogs — e.g. Bot Mode's Advanced section): tab + * state lives in local React state instead of the route's `?tab=` param, + * so an embedding dialog never fights the page router. */ + embedded?: boolean + /** Pin the WHOLE view to one profile: the scope selector is hidden and + * every tab reads/writes THAT profile. This is the plugin door — Bot Mode + * renders the real Capabilities surface pinned to a bot. */ + fixedProfile?: string + /** Pin the view to a REGISTERED gateway connection alongside `fixedProfile`: + * every read/write routes to that machine's backend instead of the active + * gateway. `''`/`'local'` mean the local pool. This is Bot Mode's + * remote-target door — a bot living on another registered gateway gets the + * live surface pointed at ITS backend. Ignored without `fixedProfile`. */ + fixedConnection?: string +} + +/** The Capabilities page SHELL: tab selection, the search header, the profile / + * connection scope, the refresh hotkey, and the dispatch to one tab component + * per tab. Each tab owns its list, detail pane and writes; the two installed + * lists are fetched here because the tab pills count them for the tab the user + * is NOT on. */ +export function CapabilitiesView({ + embedded = false, + fixedConnection, + fixedProfile, + setStatusbarItemGroup: _setStatusbarItemGroup, + ...props +}: CapabilitiesViewProps) { + const { t } = useI18n() + // Both hooks run unconditionally (rules of hooks); embedded picks the local + // one so tab clicks inside a dialog don't rewrite the page URL. + const routeTab = useRouteEnumParam('tab', CAPABILITY_MODES, 'skills') + const localTab = useState('skills') + const [mode, setMode] = embedded ? localTab : routeTab + // $gateway only feeds the MCP tab — gate the subscription so Skills/Toolsets + // tabs don't re-render on connect/disconnect/reconnect. + const gateway = useStoreSelector($gateway, g => (mode === 'mcp' ? g : null)) + + const [query, setQuery] = useState('') + + // Keep the docs iframe alive after the first Skills visit. + const [hubMounted, setHubMounted] = useState(mode === 'skills') + + if (mode === 'skills' && !hubMounted) { + setHubMounted(true) + } + + const scope = useCapabilityScope({ fixedConnection, fixedProfile }) + + // The two installed lists the tab pills count. They are fetched here, as a + // pair, because the counts stay live for the tab the user is NOT on. + const { data: skills, isError: skillsFailed, error: skillsError } = useSkillsQuery(scope.profile) + const { data: toolsets, isError: toolsetsFailed } = useToolsetsQuery(scope.profile) + const installedSkillNames = useMemo(() => new Set((skills ?? []).map(skill => skill.name)), [skills]) + + const refreshCapabilities = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: OFFICIAL_SKILLS_KEY }) + ]) + + invalidateSlashCompletions() + + void refreshToolCalls(scope.profile) + }, [scope.profile]) + + useRefreshHotkey(refreshCapabilities) + + // Rotating placeholder nudges from the user's own data — teach that search + // understands categories and tool names, not just titles. + const searchHints = useMemo(() => { + if (mode === 'skills' && skills?.length) { + return skillSearchTerms(skills).map(term => t.common.tryHint(term)) + } + + if (mode === 'toolsets' && toolsets?.length) { + return toolsetSearchTerms(toolsets).map(term => t.common.tryHint(term)) + } + + return undefined + }, [mode, skills, t, toolsets]) + + // MCP and Plugins load independently of the installed Skills/Tools lists. + const gated = mode === 'toolsets' || mode === 'skills' + const pending = gated && !(skills && toolsets) + + const loadGate = !pending ? null : skillsFailed || toolsetsFailed ? ( + void refreshCapabilities()} size="sm"> + {t.skills.refresh} + + } + description={skillsError instanceof Error ? skillsError.message : undefined} + icon="error" + title={t.skills.skillsLoadFailed} + /> + ) : ( + + ) + + // One entry per tab. Each is keyed on the scope so switching profile or + // connection is a fresh tab — never one profile's selection, open editor or + // pending install left standing over another profile's backend. + const tabContent = { + // The gateway instance backs ONLY the live `reload.mcp` RPC, and it is the + // ACTIVE gateway's socket — for a scope pinned to a different backend that + // RPC would hot-reload the wrong machine's MCP servers, so it is withheld + // (config edits still apply on that backend's next session). + mcp: () => ( + + ), + // Agent plugins for the scoped profile (selector in the section header), + // app-level desktop plugins, and the docs catalog picker underneath. + plugins: () => ( + 1 ? : undefined} + /> + ), + skills: () => ( + void refreshCapabilities()} + profile={scope.profile} + query={query} + skills={skills ?? []} + /> + ), + toolsets: () => ( + + ) + } satisfies Record React.ReactNode> + + return ( + setMode(id as CapabilityMode)} + // MCP manages a handful of entries with the editor right there — + // searching it is noise. + searchHidden={mode === 'mcp' || mode === 'plugins'} + searchHints={searchHints} + searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets} + searchValue={query} + tabs={[ + { id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null }, + { id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null }, + { id: 'mcp', label: t.skills.tabMcp }, + { id: 'plugins', label: t.skills.tabPlugins } + ]} + > + {/* One shared column: the scope selector sits above whichever tab is + active, so Skills / Tools / MCP all read and write the SAME selected + profile. */} +
+ {mode !== 'plugins' && } +
+
+ {loadGate ?? tabContent[mode]()} +
+ {hubMounted && ( +
+
+
+ ) +} + +// Feature-detection flag for plugins (Bot Mode): TRUE means this build's +// CapabilitiesView routes `fixedConnection` to the pinned connection's backend. +// Older builds export CapabilitiesView WITHOUT the prop — passing it there would +// silently read/write the ACTIVE gateway under the remote bot's profile name, +// which is exactly the wrong-machine bug the prop exists to prevent. A static +// property is probe-able without rendering. +CapabilitiesView.supportsFixedConnection = true as const diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/capabilities/mcp/mcp-tab.tsx similarity index 99% rename from apps/desktop/src/app/skills/mcp-tab.tsx rename to apps/desktop/src/app/capabilities/mcp/mcp-tab.tsx index 959405f0ee..719533452a 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/capabilities/mcp/mcp-tab.tsx @@ -45,13 +45,13 @@ import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $activeSessionId } from '@/store/session' -import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record' -import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' -import { useProfileSwitchLatch } from '../hooks/use-profile-switch-latch' -import { DetailPane, ICON_BUTTON, MASTER_DETAIL_WIDE_COLS } from '../master-detail' -import { PanelAddButton, PanelEmpty } from '../overlays/panel' -import { prettyName } from '../settings/helpers' -import { useDeepLinkHighlight } from '../settings/use-deep-link-highlight' +import { hermesConfigCacheWriter, useHermesConfigRecord } from '../../hooks/use-config-record' +import { useOnProfileSwitch } from '../../hooks/use-on-profile-switch' +import { useProfileSwitchLatch } from '../../hooks/use-profile-switch-latch' +import { DetailPane, ICON_BUTTON, MASTER_DETAIL_WIDE_COLS } from '../../master-detail' +import { PanelAddButton, PanelEmpty } from '../../overlays/panel' +import { prettyName } from '../../settings/helpers' +import { useDeepLinkHighlight } from '../../settings/use-deep-link-highlight' // The editor always speaks the ecosystem's mcp.json document format — names // are the JSON keys, transport is inferred from `command` vs `url` — so any diff --git a/apps/desktop/src/app/skills/plugin-packages.test.ts b/apps/desktop/src/app/capabilities/plugins/plugin-packages.test.ts similarity index 100% rename from apps/desktop/src/app/skills/plugin-packages.test.ts rename to apps/desktop/src/app/capabilities/plugins/plugin-packages.test.ts diff --git a/apps/desktop/src/app/skills/plugin-packages.ts b/apps/desktop/src/app/capabilities/plugins/plugin-packages.ts similarity index 100% rename from apps/desktop/src/app/skills/plugin-packages.ts rename to apps/desktop/src/app/capabilities/plugins/plugin-packages.ts diff --git a/apps/desktop/src/app/capabilities/plugins/plugins-tab.test.tsx b/apps/desktop/src/app/capabilities/plugins/plugins-tab.test.tsx new file mode 100644 index 0000000000..219ea3686a --- /dev/null +++ b/apps/desktop/src/app/capabilities/plugins/plugins-tab.test.tsx @@ -0,0 +1,456 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $pluginRecords } from '@/contrib/plugins-store' +import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins' +import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes' +import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request' +import { $connection } from '@/store/session' + +import { PluginsTab } from './plugins-tab' + +const requestGateway = vi.fn(async () => ({ plugins: [] })) + +const connectionFixture = { + baseUrl: 'http://localhost', + isFullscreen: false, + logs: [], + nativeOverlayWidth: 0, + token: '', + windowButtonPosition: null, + wsUrl: '' +} + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +describe('PluginsTab', () => { + beforeEach(() => { + $pluginRecords.set({}) + $agentPlugins.set([]) + $agentPluginsStatus.set('ready') + closePluginInstallRequest() + requestGateway.mockClear() + }) + + afterEach(() => { + cleanup() + $connection.set(null) + }) + + it('lists the scoped profile agent plugins with toggles', () => { + $agentPlugins.set([ + { + description: 'A test plugin', + key: 'demo-plugin', + name: 'demo-plugin', + source: 'git', + status: 'enabled', + version: '1.0.0' + } + ]) + + render() + + expect(screen.getByText('demo-plugin')).toBeTruthy() + expect(screen.getByRole('switch', { name: 'Agent: demo-plugin' }).getAttribute('aria-checked')).toBe('true') + }) + + it('hides bundled plugins (managed from their own surfaces)', () => { + $agentPlugins.set([ + { + description: '', + key: 'image_gen/fal', + name: 'fal', + source: 'bundled', + status: 'enabled', + version: '' + } + ]) + + render() + + expect(screen.queryByText('fal')).toBeNull() + expect(screen.getByText(/No plugins yet/)).toBeTruthy() + }) + + // A desktop half can only be copied out of a backend that runs on THIS + // machine; against a remote one the reconcile is a structural no-op, so the + // row must say so instead of pending forever (#114079). + it('marks a remote-backend desktop half unavailable instead of forever copying', () => { + $connection.set({ ...connectionFixture, mode: 'remote' }) + $agentPlugins.set([ + { + description: '', + has_desktop_half: true, + key: 'nous-prices', + name: 'nous-prices', + source: 'catalog', + status: 'enabled', + version: '1' + } + ]) + + render() + + const detail = within(screen.getByRole('row', { name: /^nous-prices/ })) + expect(detail.getByText('unavailable (remote backend)')).toBeTruthy() + expect(detail.queryByText('copying…')).toBeNull() + }) + + it('keeps the pending desktop-half state on a local backend', () => { + $agentPlugins.set([ + { + description: '', + has_desktop_half: true, + key: 'nous-prices', + name: 'nous-prices', + source: 'catalog', + status: 'enabled', + version: '1' + } + ]) + + render() + + const detail = within(screen.getByRole('row', { name: /^nous-prices/ })) + expect(detail.getByText('copying…')).toBeTruthy() + expect(detail.queryByText('unavailable (remote backend)')).toBeNull() + }) + + it('renders a unified package as ONE row with a Desktop switch and an Agent switch', () => { + $pluginRecords.set({ + media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' } + }) + $agentPlugins.set([ + { + description: '', + key: 'hermes-media-studio', + name: 'hermes-media-studio', + source: 'git', + status: 'disabled', + version: '1' + } + ]) + + render() + + expect(screen.getAllByTestId(/^plugin-row-/)).toHaveLength(1) + expect(screen.getByText('Agent + Desktop')).toBeTruthy() + expect(screen.getByRole('switch', { name: 'Desktop: Media Studio' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('switch', { name: 'Agent: Media Studio' }).getAttribute('aria-checked')).toBe('false') + expect(screen.getAllByText('Agent in workbot').length).toBeGreaterThan(0) + }) + + it('offers "Install here" for a desktop half whose agent half is not in the selected profile', async () => { + $pluginRecords.set({ + media: { + id: 'media', + name: 'Media Studio', + kind: 'disk', + status: 'loaded', + packageName: 'hermes-media-studio', + packageOrigin: { repo: 'https://github.com/NousResearch/hermes-media-studio.git', sha: 'abc' } + } + }) + + render() + + expect(screen.queryByRole('switch', { name: /^Agent:/ })).toBeNull() + screen.getByRole('button', { name: 'Install here' }).click() + // Pre-filled from the package marker: repo + pinned sha, agent half only. + await waitFor(() => { + expect($pluginInstallRequest.get()).toMatchObject({ + legacyHint: 'agent', + profile: 'workbot', + repo: 'https://github.com/NousResearch/hermes-media-studio.git', + sha: 'abc' + }) + }) + }) + + it('disables "Install here" when the package has no known origin (hand-copied folder)', () => { + $pluginRecords.set({ + media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' } + }) + + render() + + expect((screen.getByRole('button', { name: 'Install here' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('loads the plugin list scoped to the selected profile', () => { + render() + + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'list', profile: 'workbot' }) + ) + }) + + it('opens the dual-target install modal from a catalog pick message', async () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'weather-plugin', + repo: 'https://github.com/example/weather-plugin', + sha: 'a'.repeat(40), + subdir: '', + tier: 'community', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => { + const request = $pluginInstallRequest.get() + + expect(request).not.toBeNull() + expect(request?.catalogName).toBe('weather-plugin') + expect(request?.repo).toBe('https://github.com/example/weather-plugin') + expect(request?.profile).toBe('workbot') + expect(request?.sha).toBe('a'.repeat(40)) + }) + }) + + it('ignores pick messages from foreign origins', () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'evil-plugin', + repo: 'https://github.com/evil/evil-plugin', + type: 'hermes-plugin-pick' + }, + origin: 'https://evil.example.com' + }) + ) + + expect($pluginInstallRequest.get()).toBeNull() + }) + + it('toggles by canonical key through plugins.manage', async () => { + $agentPlugins.set([ + { + description: '', + key: 'image_gen/legacy', + name: 'Legacy plugin', + source: 'user', + status: 'disabled', + version: '0.20.0' + } + ]) + requestGateway.mockResolvedValueOnce({ + ok: true, + plugin: { key: 'image_gen/legacy', name: 'Legacy plugin', status: 'enabled' } + } as never) + + render() + + screen.getByRole('switch', { name: 'Agent: Legacy plugin' }).click() + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'toggle', key: 'image_gen/legacy', enable: true }) + ) + ) + }) + + it('renders keyless rows read-only (no name-addressed toggle RPC)', () => { + // Name-addressed toggles flip every same-named plugin across category + // dirs — pre-contract-v6 rows must never reach the RPC. + $agentPlugins.set([ + { + description: 'Returned by a pre-key backend', + name: 'Legacy plugin', + source: 'user', + status: 'disabled', + version: '0.20.0' + } + ]) + + render() + + const toggle = screen.getByRole('switch', { name: 'Agent: Legacy plugin' }) + + expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true) + + toggle.click() + + expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' })) + }) + + it('appends the subdir fragment for multi-plugin repos', async () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'nested-plugin', + repo: 'https://github.com/example/plugins-monorepo', + subdir: 'nested-plugin', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => { + expect($pluginInstallRequest.get()?.repo).toBe('https://github.com/example/plugins-monorepo#nested-plugin') + }) + }) +}) + +describe('PluginsTab catalog UX', () => { + beforeEach(() => { + $agentPlugins.set([]) + $agentPluginsStatus.set('ready') + closePluginInstallRequest() + requestGateway.mockClear() + setPaneHeightOverride('capabilities-plugin-catalog', undefined) + }) + + afterEach(cleanup) + + it('grows the catalog when its top-edge sash is dragged up, and resets on double-click', () => { + // jsdom has no layout: give the Capabilities column a real height so the + // "never crush the lists above" clamp has something to clamp against. + const clientHeight = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(900) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 }) + render() + const sash = screen.getByTestId('plugin-catalog-sash') + + fireEvent.pointerDown(sash, { button: 0, clientY: 600 }) + fireEvent.pointerMove(window, { clientY: 400 }) + fireEvent.pointerUp(window) + + // Default 380px + 200px of upward drag (clamped only by window/column size). + expect($paneHeightOverride('capabilities-plugin-catalog').get()).toBe(580) + + fireEvent.doubleClick(sash) + expect($paneHeightOverride('capabilities-plugin-catalog').get()).toBeUndefined() + clientHeight.mockRestore() + }) + + it('shows an Update chip when the catalog pin moved past the installed SHA', () => { + $agentPlugins.set([ + { + catalog_name: 'demo-weather', + catalog_sha: 'b'.repeat(40), + catalog_tier: 'community', + description: '', + installed_sha: 'a'.repeat(40), + key: 'demo-weather', + name: 'demo-weather', + source: 'git', + status: 'enabled', + update_available: true, + version: '1.0.0' + } + ]) + + render() + + expect(screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` })).toBeTruthy() + }) + + it('re-pins through plugins.manage update when the chip is clicked', async () => { + $agentPlugins.set([ + { + catalog_name: 'demo-weather', + catalog_sha: 'b'.repeat(40), + catalog_tier: 'community', + description: '', + installed_sha: 'a'.repeat(40), + key: 'demo-weather', + name: 'demo-weather', + source: 'git', + status: 'enabled', + update_available: true, + version: '1.0.0' + } + ]) + requestGateway.mockResolvedValue({ ok: true, unchanged: false, plugins: [] } as never) + + render() + + screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` }).click() + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'update', name: 'demo-weather', profile: 'workbot' }) + ) + ) + }) + + it('refuses a catalog pick that is already installed and current', async () => { + $agentPlugins.set([ + { + catalog_name: 'demo-weather', + description: '', + installed_sha: 'a'.repeat(40), + key: 'demo-weather', + name: 'demo-weather', + source: 'git', + status: 'enabled', + update_available: false, + version: '1.0.0' + } + ]) + + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'demo-weather', + repo: 'https://github.com/example/demo-weather', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + // The modal must NOT open — the pick is refused with a toast. + await new Promise(resolve => setTimeout(resolve, 20)) + expect($pluginInstallRequest.get()).toBeNull() + }) + + it('still opens the modal for an installed pick when an update is available', async () => { + $agentPlugins.set([ + { + catalog_name: 'demo-weather', + description: '', + installed_sha: 'a'.repeat(40), + key: 'demo-weather', + name: 'demo-weather', + source: 'git', + status: 'enabled', + update_available: true, + version: '1.0.0' + } + ]) + + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'demo-weather', + repo: 'https://github.com/example/demo-weather', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => expect($pluginInstallRequest.get()).not.toBeNull()) + }) +}) diff --git a/apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx b/apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx new file mode 100644 index 0000000000..8f96f3d26f --- /dev/null +++ b/apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx @@ -0,0 +1,655 @@ +import { useStore } from '@nanostores/react' +import { + memo, + type ReactNode, + type PointerEvent as ReactPointerEvent, + useEffect, + useMemo, + useRef, + useState +} from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' +import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store' +import { discoverRuntimePlugins } from '@/contrib/runtime-loader' +import type { ProfileScope } from '@/hermes' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { FolderOpen, Loader2, Monitor, Package, RefreshCw } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $agentPluginBusy, + $agentPlugins, + $agentPluginsError, + $agentPluginsStatus, + type AgentPluginRow, + type GatewayRequest, + isDesktopRelevantPlugin, + loadAgentPlugins, + toggleAgentPlugin, + updateAgentPlugin +} from '@/store/agent-plugins' +import { notify, notifyError } from '@/store/notifications' +import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes' +import { openPluginInstallRequest } from '@/store/plugin-install-request' +import { $connection } from '@/store/session' + +import { PanelEmpty } from '../../overlays/panel' +import { Pill } from '../../settings/primitives' +import { useDeepLinkHighlight } from '../../settings/use-deep-link-highlight' + +import { mergePluginPackages, type PackageKind, type PluginPackage } from './plugin-packages' + +// The REAL Plugin Catalog page (docs site) embedded as a one-click picker — +// the same pattern as the Skills tab's EmbeddedHubPicker. `?embed=picker` +// hides the docs chrome and adds "+ Add to this Agent" per card, which posts +// { type: 'hermes-plugin-pick', name, repo, sha, subdir, tier, installCmd } +// to the parent window. We validate the origin and open the shared +// dual-target install modal (agent half → catalog-pinned install into the +// scoped profile; desktop half → this app), so unified packages install both +// halves in one flow. +const CATALOG_ORIGIN = 'https://hermes-agent.nousresearch.com' +const CATALOG_PICKER_URL = `${CATALOG_ORIGIN}/docs/plugins?embed=picker` + +// Catalog viewport: persisted through the shared pane store, dragged from the +// section's TOP edge ("pull the catalog up"), clamped so neither the catalog +// nor the plugin list above can vanish. Same contract as EmbeddedHubPicker. +const CATALOG_PANE_ID = 'capabilities-plugin-catalog' +const CATALOG_DEFAULT_PX = 380 +const CATALOG_MIN_PX = 120 +const CATALOG_MAX_VH = 0.75 +const CATALOG_COLLAPSED_PX = 4 +const CATALOG_LIST_RESERVED_PX = 176 + +interface PluginPickMessage { + installCmd?: string + name?: string + repo?: string + sha?: string + subdir?: string + tier?: string + type?: string +} + +/** Deep-link anchor for a package row (`/capabilities?tab=plugins&plugin=`). + * Accepts the agent key, the agent name, or the desktop record id. */ +export const pluginElementId = (target: string) => `plugin-${target}` + +/** Derive the bare profile name a `plugins.manage` call should target. */ +function profileParam(scope: ProfileScope): null | string { + if (!scope) { + return null + } + + return typeof scope === 'string' ? scope : (scope.profile ?? null) +} + +function reveal(file: string) { + void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined) +} + +async function revealPluginsDir() { + try { + // Electron owns the app-level plugin root — deriving it from the backend's + // hermes_home breaks against a remote backend (#66899). + const dir = await window.hermesDesktop?.desktopPluginsRoot?.() + + if (!dir) { + notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder') + + return + } + + const result = await window.hermesDesktop?.openDir?.(dir) + + if (result && !result.ok) { + notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder') + } + } catch (err) { + notifyError(err, 'Could not resolve the plugins folder') + } +} + +/** Copy any changed unified desktop halves into the app root FIRST, then + * rescan the root — a concurrent scan would read the pre-copy state. */ +async function rescanAll(requestGateway: GatewayRequest, scope: null | string) { + await window.hermesDesktop?.reconcileDesktopPlugins?.().catch(() => undefined) + await discoverRuntimePlugins() + await loadAgentPlugins(requestGateway, scope) +} + +/** Open the dual-target install modal pre-filled to install ONLY the agent + * half of a unified package into the scoped profile (the desktop half is + * already here). Provenance comes from the package marker Electron stamped + * when it copied the half out (catalog sidecar or git remote). */ +function installAgentHalfHere(record: PluginRecord, profile: null | string) { + const origin = record.packageOrigin + + if (!origin?.repo) { + return + } + + openPluginInstallRequest({ + catalogName: origin.catalogName, + legacyHint: 'agent', + profile, + repo: origin.repo, + sha: origin.sha + }) +} + +function KindBadge({ kind }: { kind: PackageKind }) { + const { t } = useI18n() + const p = t.skills.plugins + + return ( + + {kind !== 'desktop' && } + {kind !== 'agent' && } + {kind === 'both' ? p.kindBoth : kind === 'agent' ? p.kindAgent : p.kindDesktop} + + ) +} + +/** Provenance pill: where the package came from. */ +function ProvenancePill({ pkg }: { pkg: PluginPackage }) { + const { t } = useI18n() + const p = t.skills.plugins + + if (pkg.agent?.catalog_name) { + return ( + + + {pkg.agent.catalog_tier === 'official' ? p.tierOfficial : p.tierCommunity} + + + ) + } + + if (pkg.agent?.pinned_sha) { + return ( + + + + {p.pinnedBadge(pkg.agent.pinned_sha.slice(0, 8))} + + + + ) + } + + if (pkg.agent) { + return {pkg.agent.source} + } + + if (pkg.desktop) { + return {t.settings.plugins.kinds[pkg.desktop.kind]} + } + + return null +} + +/** Column widths shared by the header and every row so the two control + * columns line up down the page like a table. */ +const HALF_COL = 'flex w-36 shrink-0 items-center gap-1.5' + +/** One control cell; `label` is the accessible name for screen readers only + * (the visible column label lives once, in the header). */ +function HalfCell({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Dash() { + return ( + + — + + ) +} + +function PackageRow({ + pkg, + scope, + scopeLabel, + busy, + onAgentToggle, + onAgentUpdate +}: { + pkg: PluginPackage + scope: null | string + scopeLabel: string + busy: boolean + onAgentToggle: (row: AgentPluginRow, enable: boolean) => void + onAgentUpdate: (row: AgentPluginRow) => void +}) { + const { t } = useI18n() + const p = t.skills.plugins + const d = t.settings.plugins + const desktop = pkg.desktop + const agent = pkg.agent + const desktopOn = desktop ? desktop.status !== 'disabled' : false + const agentOn = agent?.status === 'enabled' + const agentToggleable = Boolean(agent?.key) + // Electron's desktop-half reconcile only walks THIS machine's homes, so a + // package installed on a remote backend can never materialize here (#114079). + const remoteBackend = useStore($connection)?.mode === 'remote' + + return ( +
+
+
+
+ {pkg.name} + {agent?.version && v{agent.version}} + + + {agent?.portable && {p.portableBadge}} + {desktop?.status === 'error' && {d.failed}} +
+ {(desktop?.status === 'error' ? desktop.error : pkg.description) && ( +
+ {desktop?.status === 'error' ? desktop.error : pkg.description} +
+ )} +
+ {/* Fixed slot so the switch column stays straight whether or not + this row has a folder to reveal (bundled plugins have none). */} + + {desktop?.file && ( + + + + )} + +
+ + {/* The two halves. Desktop is app-level and reads the same whichever + profile is selected; Agent follows the selector. A half the package + lacks shows a dash; a half it has but which is missing on this side + shows the install affordance. */} + + {desktop ? ( + { + triggerHaptic('selection') + void setPluginEnabled(desktop.id, on) + }} + /> + ) : pkg.desktopMissing ? ( + + + {remoteBackend ? p.desktopHalfRemote : p.desktopHalfPending} + + + ) : ( + + )} + + + + {agent ? ( + <> + {agent.update_available && ( + + )} + {busy && } + {agentToggleable ? ( + onAgentToggle(agent, on)} + /> + ) : ( + + + + + + )} + + ) : pkg.agentMissingInProfile && desktop ? ( + + + + + + ) : ( + + )} + +
+ ) +} + +/** THE plugins surface: one row per package. Each row shows its Desktop half + * (this app — the same for every profile, gateway, or machine) and its Agent + * half (the selected profile's backend). Discovery sits underneath: the live + * catalog picker plus Install from Git for anything not in the catalog. */ +export const PluginsTab = memo(function PluginsTab({ + profile, + scopeSelector, + scopeLabel +}: { + profile: ProfileScope + /** The Capabilities profile selector; rendered in the Agent column header so + * it visibly governs only that column. */ + scopeSelector?: ReactNode + /** Display name of the selected profile for the Agent column label. */ + scopeLabel?: string +}) { + const { t } = useI18n() + const p = t.skills.plugins + const d = t.settings.plugins + const { requestGateway } = useGatewayRequest() + + const desktopRecords = useStore($pluginRecords) + const agentRows = useStore($agentPlugins) + const status = useStore($agentPluginsStatus) + const error = useStore($agentPluginsError) + const busyKey = useStore($agentPluginBusy) + + const scope = profileParam(profile) + const label = scopeLabel ?? scope ?? t.skills.plugins.defaultProfile + + useEffect(() => { + void loadAgentPlugins(requestGateway, scope) + }, [requestGateway, scope]) + + const packages = useMemo( + () => mergePluginPackages(Object.values(desktopRecords), agentRows.filter(isDesktopRelevantPlugin)), + [agentRows, desktopRecords] + ) + + useDeepLinkHighlight({ param: 'plugin', ready: () => true, elementId: pluginElementId }) + + // Catalog picker viewport (persisted height, collapse toggle, top-edge sash). + const heightOverride = useStore($paneHeightOverride(CATALOG_PANE_ID)) + const height = heightOverride ?? CATALOG_DEFAULT_PX + const open = height > CATALOG_COLLAPSED_PX + const [pickerMounted, setPickerMounted] = useState(open) + const [dragging, setDragging] = useState(false) + const sectionRef = useRef(null) + + if (open && !pickerMounted) { + setPickerMounted(true) + } + + const startDrag = (event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + const startY = event.clientY + const startHeight = height + const column = sectionRef.current?.parentElement + const columnMax = column ? column.clientHeight - CATALOG_LIST_RESERVED_PX : Number.POSITIVE_INFINITY + const max = Math.max(CATALOG_MIN_PX, Math.round(Math.min(window.innerHeight * CATALOG_MAX_VH, columnMax))) + setDragging(true) + + const onMove = (move: globalThis.PointerEvent) => { + setPaneHeightOverride( + CATALOG_PANE_ID, + Math.round(Math.min(max, Math.max(CATALOG_MIN_PX, startHeight + (startY - move.clientY)))) + ) + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove) + setDragging(false) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp, { once: true }) + } + + useEffect(() => { + if (!open) { + return undefined + } + + const onMessage = (event: MessageEvent) => { + if (event.origin !== CATALOG_ORIGIN) { + return + } + + const data = event.data as null | PluginPickMessage + + if (!data || data.type !== 'hermes-plugin-pick' || !data.name || !data.repo) { + return + } + + const existing = $agentPlugins.get().find(row => row.catalog_name === data.name || row.name === data.name) + + if (existing && !existing.update_available) { + notify({ kind: 'success', message: p.alreadyInstalled(String(data.name)) }) + + return + } + + openPluginInstallRequest({ + catalogName: String(data.name), + profile: scope, + repo: data.subdir ? `${String(data.repo)}#${String(data.subdir)}` : String(data.repo), + sha: data.sha ? String(data.sha) : undefined + }) + } + + window.addEventListener('message', onMessage) + + return () => window.removeEventListener('message', onMessage) + }, [open, p, scope]) + + const agentBusy = (row: AgentPluginRow) => busyKey === (row.key ?? row.name) || busyKey === row.name + + return ( +
+
+ {/* Header: what the two columns mean, and the controls that act on + the whole page (install, folder, rescan). */} +
+

+ {p.pageBlurb} +

+
+ + + + + + + +
+
+ + {status === 'error' ? ( + void loadAgentPlugins(requestGateway, scope)} size="sm"> + {t.skills.refresh} + + } + description={error ?? undefined} + icon="error" + title={p.loadFailed} + /> + ) : packages.length === 0 && status === 'ready' ? ( +

+ {p.emptyAll} {p.emptyHint} +

+ ) : ( +
+ {/* Column header: the visible labels for the two control columns, + aligned with the cells below. The profile selector sits INSIDE + the Agent header so it visibly governs only that column. */} +
+
+
+ + + {p.halfDesktop} + +
+
+ + {scopeSelector ?? {p.halfAgentIn(label)}} +
+
+ {packages.map(pkg => ( + { + if (!row.key) { + return + } + + void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope) + }} + onAgentUpdate={row => { + void updateAgentPlugin(requestGateway, row.name, p.updateFailed(row.name), scope).then(applied => { + if (applied) { + notify({ kind: 'success', message: p.updated(row.name) }) + void rescanAll(requestGateway, scope) + } + }) + }} + pkg={pkg} + scope={scope} + scopeLabel={label} + /> + ))} +
+ )} +
+ +
+
setPaneHeightOverride(CATALOG_PANE_ID, undefined)} + onPointerDown={startDrag} + > +
+
+
+ + {p.catalogTitle} + + +
+ {pickerMounted && ( +
+
+