diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb59b032a1..f205717a632 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ name: CI on: pull_request: - branches: [main] push: branches: [main] diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 69fa5d162cf..48b1f94a6f0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,7 +16,6 @@ on: # reports a status (path-gated workflows leave checks "pending" forever # when no matching files change, which blocks merge). pull_request: - branches: [main] release: types: [published] diff --git a/Dockerfile b/Dockerfile index b4ebd093697..c01de9857bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -290,6 +290,19 @@ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui ENV HERMES_HOME=/opt/data ENV HERMES_WRITE_SAFE_ROOT=/opt/data ENV HERMES_DISABLE_LAZY_INSTALLS=1 +# The published image seals /opt/hermes (root-owned, read-only) so a runtime +# lazy install can't mutate the agent's own venv and brick it. But opt-in +# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in +# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml +# policy 2026-05-12: one quarantined release must not break every install). +# Redirect those lazy installs to a writable dir on the durable data volume. +# lazy_deps appends this dir to the END of sys.path, so a package installed +# here can only ADD modules — it can never shadow or downgrade a core module, +# so the sealed-venv guarantee holds even with installs re-enabled. The dir +# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives +# on the /opt/data volume, so it persists across container recreates / image +# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter). +ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages # `docker exec` privilege-drop shim. When operators run # `docker exec hermes ...` they default to root, and any file the diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 9ce6281824c..5048b702598 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -23,6 +23,11 @@ except ModuleNotFoundError: # new code but ``uv pip install -e .`` didn't finish. Missing bootstrap # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. pass +else: + # Stop a ``utils/``/``proxy/``/``ui/`` package in the launch directory from + # shadowing Hermes's own modules — ``hermes acp`` can be started from any + # cwd, including a project that has same-named packages on its path. + hermes_bootstrap.harden_import_path() import argparse import asyncio diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index b913e1043af..2958be0ce02 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -74,7 +74,7 @@ _POLISHED_TOOLS = { "kanban_create", "kanban_show", "kanban_comment", "kanban_complete", "kanban_block", "kanban_link", "kanban_heartbeat", "yb_query_group_info", "yb_query_group_members", "yb_search_sticker", - "yb_send_dm", "yb_send_sticker", "mixture_of_agents", + "yb_send_dm", "yb_send_sticker", } diff --git a/agent/agent_init.py b/agent/agent_init.py index e7f2ed9eac3..180e1d971b0 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -106,7 +106,12 @@ def _custom_provider_extra_body_for_agent( base_url: str, custom_providers: List[Dict[str, Any]], ) -> Optional[Dict[str, Any]]: - if (provider or "").strip().lower() != "custom": + provider_norm = (provider or "").strip().lower() + if provider_norm == "custom": + provider_key_filter = "" + elif provider_norm.startswith("custom:"): + provider_key_filter = provider_norm.split(":", 1)[1].strip() + else: return None target_url = _normalized_custom_base_url(base_url) @@ -117,6 +122,13 @@ def _custom_provider_extra_body_for_agent( for entry in custom_providers or []: if not isinstance(entry, dict): continue + if provider_key_filter: + entry_keys = { + str(entry.get("provider_key", "") or "").strip().lower(), + str(entry.get("name", "") or "").strip().lower(), + } + if provider_key_filter not in entry_keys: + continue if _normalized_custom_base_url(entry.get("base_url")) != target_url: continue extra_body = entry.get("extra_body") @@ -707,6 +719,15 @@ def init_agent( print("🔑 Using credentials: Microsoft Entra ID") elif isinstance(effective_key, str) and len(effective_key) > 12: print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") + elif agent.provider == "moa": + from agent.moa_loop import MoAClient + agent.api_mode = "chat_completions" + agent.client = MoAClient(agent.model or "default") + agent._client_kwargs = {} + agent.api_key = api_key or "moa-virtual-provider" + agent.base_url = base_url or "moa://local" + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with MoA preset: {agent.model}") elif agent.api_mode == "bedrock_converse": # AWS Bedrock — uses boto3 directly, no OpenAI client needed. # Region is extracted from the base_url or defaults to us-east-1. @@ -1506,6 +1527,7 @@ def init_agent( # 3. Check general plugin system (user-installed plugins) # 4. Fall back to built-in ContextCompressor _selected_engine = None + _copy_failed = False _engine_name = "compressor" # default try: _ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {} @@ -1523,15 +1545,35 @@ def init_agent( # Try general plugin system as fallback if _selected_engine is None: + _candidate = None try: from hermes_cli.plugins import get_plugin_context_engine _candidate = get_plugin_context_engine() - if _candidate and _candidate.name == _engine_name: - _selected_engine = _candidate except Exception: - pass + _candidate = None + if _candidate is not None and _candidate.name == _engine_name: + # Deep-copy the shared plugin singleton so a child agent's + # update_model() can't mutate the parent's compressor (#42449). + # Copy can fail for engines holding uncopyable state (locks, DB + # connections, clients); in that case fall back to the built-in + # compressor with an ACCURATE message rather than silently + # mislabelling it "not found". + import copy + try: + _selected_engine = copy.deepcopy(_candidate) + except Exception as _copy_err: + _copy_failed = True + _ra().logger.warning( + "Context engine '%s' could not be safely copied for this " + "agent (%s) — falling back to built-in compressor. Plugin " + "engines that hold uncopyable state (locks, DB connections) " + "should implement __deepcopy__ to copy only mutable budget " + "state.", + _engine_name, _copy_err, + ) + _selected_engine = None - if _selected_engine is None: + if _selected_engine is None and not _copy_failed: _ra().logger.warning( "Context engine '%s' not found — falling back to built-in compressor", _engine_name, @@ -1621,16 +1663,27 @@ def init_agent( for t in agent.tools if isinstance(t, dict) } - for _schema in agent.context_compressor.get_tool_schemas(): - _tname = _schema.get("name", "") - if _tname and _tname in _existing_tool_names: + from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema + for _raw_schema in agent.context_compressor.get_tool_schemas(): + _schema = _normalize_tool_schema(_raw_schema) + if _schema is None: + # A schema with no resolvable name (e.g. an already-wrapped + # entry) would append a nameless tool that strict providers + # 400 on, disabling the whole toolset (#47707). Skip it. + _ra().logger.warning( + "Context engine returned a tool schema with no resolvable " + "name; skipping to avoid poisoning the request (%r)", + _raw_schema, + ) + continue + _tname = _schema["name"] + if _tname in _existing_tool_names: continue # already registered via plugin/cache path _wrapped = {"type": "function", "function": _schema} agent.tools.append(_wrapped) - if _tname: - agent.valid_tool_names.add(_tname) - agent._context_engine_tool_names.add(_tname) - _existing_tool_names.add(_tname) + agent.valid_tool_names.add(_tname) + agent._context_engine_tool_names.add(_tname) + _existing_tool_names.add(_tname) # Notify context engine of session start if hasattr(agent, "context_compressor") and agent.context_compressor: diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index ccf15307b07..3473eb0b54c 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1697,6 +1697,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo old_model, old_provider, new_model, new_provider, ) + # ── Persist billing route to session DB ── + # The agent's _session_db / session_id may not be set in all contexts + # (tests, bare agents without a session DB, etc.). This ensures the + # dashboard Model cards show the actual provider after a mid-session + # /model switch instead of the stale session-creation provider. + # See #48248 for the full bug description. + _session_db = getattr(agent, "_session_db", None) + _session_id = getattr(agent, "session_id", None) + if _session_db is not None and _session_id: + try: + _session_db.update_session_billing_route( + _session_id, + provider=agent.provider, + base_url=agent.base_url, + billing_mode=getattr(agent, "api_mode", None), + ) + except Exception: + logger.warning( + "Failed to persist billing route after model switch", + exc_info=True, + ) def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str, diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index c63c71da7bc..4f7595c94d5 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1297,7 +1297,15 @@ def run_oauth_setup_token() -> Optional[str]: # Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file). _OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" -_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token" +# Anthropic migrated the OAuth token endpoint to platform.claude.com; +# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS +# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary +# for backward compatibility with existing imports and now points at the live host. +_OAUTH_TOKEN_URLS = [ + "https://platform.claude.com/v1/oauth/token", + "https://console.anthropic.com/v1/oauth/token", +] +_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0] _OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback" _OAUTH_SCOPES = "org:create_api_key user:profile user:inference" _HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json" @@ -1395,18 +1403,34 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: "code_verifier": verifier, }).encode() - req = urllib.request.Request( - _OAUTH_TOKEN_URL, - data=exchange_data, - headers={ - "Content-Type": "application/json", - "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)", - }, - method="POST", - ) + # Anthropic migrated the OAuth token endpoint to platform.claude.com; + # console.anthropic.com now 404s. Try the new host first, then fall + # back to console for older deployments (mirrors the refresh path). + result = None + last_error = None + for endpoint in _OAUTH_TOKEN_URLS: + req = urllib.request.Request( + endpoint, + data=exchange_data, + headers={ + "Content-Type": "application/json", + "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode()) + break + except Exception as exc: + last_error = exc + logger.debug("Anthropic token exchange failed at %s: %s", endpoint, exc) + continue - with urllib.request.urlopen(req, timeout=15) as resp: - result = json.loads(resp.read().decode()) + if result is None: + raise last_error if last_error is not None else ValueError( + "Anthropic token exchange failed" + ) except Exception as e: print(f"Token exchange failed: {e}") return None diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 0afb0add20b..e46780d2337 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -101,6 +101,7 @@ class _OpenAIProxy: OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance from agent.credential_pool import load_pool +from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars @@ -2470,7 +2471,7 @@ def _is_payment_error(exc: Exception) -> bool: # but sometimes wrap them in 429 or other codes. # Daily quota exhaustion from Bedrock, Vertex AI, and similar providers # uses different language but is semantically identical to credit exhaustion. - if status in {402, 404, 429, None}: + if status in {402, 403, 404, 429, None}: if any(kw in err_lower for kw in ( "credits", "insufficient funds", "can only afford", "billing", @@ -2479,6 +2480,8 @@ def _is_payment_error(exc: Exception) -> bool: "balance_depleted", "no usable credits", "model_not_supported_on_free_tier", "not available on the free tier", + "requires a subscription", "upgrade for access", + "upgrade for higher limits", "reached your session usage limit", # Daily / monthly / weekly quota exhaustion keywords "quota exceeded", "quota_exceeded", "too many tokens per day", "daily limit", @@ -2697,6 +2700,60 @@ def _is_model_not_found_error(exc: Exception) -> bool: )) +def _is_model_incompatible_error(exc: Exception) -> bool: + """Detect "this route cannot serve this model" 400s (capability mismatch). + + Distinct from :func:`_is_model_not_found_error` (the model does not exist + anywhere): here the model name is valid but the *current provider/account* + is structurally unable to run it. The canonical case is a configured + fallback that cannot run the main model — e.g. an ``openai-codex`` / + ChatGPT-account fallback asked to compress a ``glm-5.2`` conversation:: + + Error code: 400 - {'detail': "The 'glm-5.2' model is not supported + when using Codex with a ChatGPT account."} + + The candidate authenticates fine and builds a client, so the auth and + payment predicates don't fire and the call would otherwise raise and + abort the whole auxiliary task (commonly compression — which then drops + middle turns and churns the session, destroying the prompt cache). + Treating it as a fallback-worthy capability error lets the chain skip the + incapable route and continue to the next candidate, mirroring the + context-window feasibility screen (#52392). + + Billing/quota 400s belong to :func:`_is_payment_error`; "model does not + exist" 400s belong to :func:`_is_model_not_found_error`. This predicate + explicitly excludes both so the three don't overlap. + """ + status = getattr(exc, "status_code", None) + if status not in {400, None}: + return False + err_lower = str(exc).lower() + # Not-found 400s ("invalid model ID", "model does not exist") are owned by + # _is_model_not_found_error. Billing/free-tier 400s are owned by the + # payment path — key on the billing keywords directly here rather than + # calling _is_payment_error(), because that predicate is status-gated + # ({402,403,404,429,None}) and would not recognise a 400-coded billing + # body, letting it leak into this capability bucket. + if _is_model_not_found_error(exc): + return False + if any(kw in err_lower for kw in ( + "credits", "insufficient funds", "billing", "out of funds", + "balance_depleted", "no usable credits", "payment required", + "free tier", "free-tier", "not available on the free tier", + "model_not_supported_on_free_tier", "quota", + )): + return False + return any(kw in err_lower for kw in ( + "is not supported when using", # codex/ChatGPT-account model gating + "model is not supported", + "not supported with this", + "not supported for this account", + "model_not_supported", + "does not support this model", + "unsupported model", + )) + + def _evict_cached_clients(provider: str) -> None: """Drop cached auxiliary clients for a provider so fresh creds are used.""" normalized = _normalize_aux_provider(provider) @@ -3147,6 +3204,88 @@ def _try_main_agent_model_fallback( return client, resolved_model or main_model, label +# ── Context-window screening for runtime fallback chains (issue #52392) ── +# +# When the runtime auxiliary fallback chain selects a candidate that is +# reachable but has a context window smaller than the compression task +# requires, the call errors out instead of continuing to the next, viable +# candidate. The startup feasibility check in +# ``agent.conversation_compression.check_compression_model_feasibility`` +# already filters too-small auxiliary models at startup, but the runtime +# fallback chain (``_try_configured_fallback_chain`` and +# ``_try_main_fallback_chain``) does not apply the same filter, so +# compression can stop at the first alive door even if the room behind it +# is too small. +# +# The helpers below screen each candidate by its effective context window +# before it is returned. ``None`` results from ``get_model_context_length`` +# are passed through (we cannot prove a model is too small, so we do not +# block it). This preserves the existing fallback surface for +# unrecognised/custom models while closing the gap on the well-known ones. + +def _task_minimum_context_length(task: Optional[str]) -> Optional[int]: + """Return the minimum context length required for an auxiliary task. + + Only ``compression`` carries an explicit minimum today (the same + ``MINIMUM_CONTEXT_LENGTH`` (64K) floor that + ``check_compression_model_feasibility`` already enforces at startup). + Other tasks (``vision``, ``title_generation``, ``web_extract``, + ``skills_hub``, ``mcp``, ``session_search``) return ``None`` — they + have no per-task context floor and the runtime chain must remain + permissive for them. + + Returns ``None`` for an empty/``None`` task name so the helper is a + safe no-op when called from generic sites. + """ + if not task: + return None + if task == "compression": + return MINIMUM_CONTEXT_LENGTH + return None + + +def _candidate_context_window( + provider: str, + model: str, + base_url: str = "", + api_key: str = "", +) -> Optional[int]: + """Resolve the effective context window for a fallback candidate. + + Thin wrapper around :func:`agent.model_metadata.get_model_context_length` + that swallows probe failures (returns ``None``). Callers treat + ``None`` as "unknown — pass through" so the existing fallback + surface is preserved when the context-length resolver chain cannot + determine a value (custom endpoints, models not in the registry, + offline endpoints). + + Best-effort, never raises — the runtime fallback chain must keep + moving even if the resolver hits a probe error. + """ + if not model: + return None + try: + ctx = get_model_context_length( + model, + base_url=base_url, + api_key=api_key, + provider=provider, + ) + except Exception as exc: + logger.debug( + "Auxiliary fallback: could not resolve context window for %s/%s: %s", + provider, model, exc, + ) + return None + # ``get_model_context_length`` returns an int (with a 256K default + # fallback when nothing else matches). We still propagate ``None`` if + # a future change returns ``Optional[int]`` — being explicit is + # cheap and the test suite covers both shapes. + if isinstance(ctx, int) and ctx > 0: + return ctx + return None + + def _try_configured_fallback_chain( task: str, failed_provider: str, @@ -3171,6 +3310,7 @@ def _try_configured_fallback_chain( skip = failed_provider.lower().strip() tried = [] + min_ctx = _task_minimum_context_length(task) for i, entry in enumerate(chain): if not isinstance(entry, dict): @@ -3188,6 +3328,20 @@ def _try_configured_fallback_chain( fb_client, resolved_model = None, None if fb_client is not None: + if min_ctx is not None and resolved_model: + fb_ctx = _candidate_context_window( + fb_provider, + resolved_model, + base_url=str(entry.get("base_url") or ""), + api_key=_fallback_entry_api_key(entry) or "", + ) + if fb_ctx is not None and fb_ctx < min_ctx: + logger.info( + "Auxiliary %s: skipping %s (%s context=%d < min=%d), continuing chain", + task, label, resolved_model, fb_ctx, min_ctx, + ) + tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})") + continue logger.info( "Auxiliary %s: %s on %s — configured fallback to %s (%s)", task, reason, failed_provider, label, resolved_model or fb_model or "default", @@ -3203,6 +3357,28 @@ def _try_configured_fallback_chain( return None, None, "" +def _try_configured_fallback_for_unavailable_client( + task: Optional[str], + failed_provider: str, +) -> Tuple[Optional[Any], Optional[str], str]: + """Try task fallback_chain when an explicit aux provider cannot build. + + This covers the "no client" case before any request is sent: missing + raw env key, unavailable OAuth/pool credentials, or provider resolver + returning ``(None, None)``. It deliberately stops at the configured + per-task fallback chain; the main-agent model remains the last-resort + runtime fallback for request-time capacity errors. + """ + explicit = (failed_provider or "").strip().lower() + if not task or not explicit or explicit in {"auto"}: + return None, None, "" + return _try_configured_fallback_chain( + task, + explicit, + reason="provider unavailable", + ) + + def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]: """Resolve inline or env-backed API key from a fallback-chain entry.""" explicit = str(entry.get("api_key") or "").strip() @@ -3261,6 +3437,7 @@ def _try_main_fallback_chain( main_norm = (_read_main_provider() or "").strip().lower() skip = {p for p in (failed_norm, main_norm, "auto") if p} tried: List[str] = [] + min_ctx = _task_minimum_context_length(task) for i, entry in enumerate(chain): if not isinstance(entry, dict): @@ -3284,6 +3461,20 @@ def _try_main_fallback_chain( logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc) fb_client, resolved_model = None, None if fb_client is not None: + if min_ctx is not None: + fb_ctx = _candidate_context_window( + fb_provider, + resolved_model or fb_model, + base_url=str(entry.get("base_url") or ""), + api_key=_fallback_entry_api_key(entry) or "", + ) + if fb_ctx is not None and fb_ctx < min_ctx: + logger.info( + "Auxiliary %s: skipping %s (context=%d < min=%d), continuing chain", + task or "call", label, fb_ctx, min_ctx, + ) + tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})") + continue logger.info( "Auxiliary %s: %s on %s — main fallback chain to %s (%s)", task or "call", reason, failed_provider or "auto", label, @@ -5344,21 +5535,30 @@ def call_llm( ) if client is None: # When the user explicitly chose a non-OpenRouter provider but no - # credentials were found, fail fast instead of silently routing - # through OpenRouter (which causes confusing 404s). + # credentials were found, honor the task fallback_chain before + # raising. Missing raw env keys are recoverable for auxiliary + # tasks because fallback entries may use OAuth / credential-pool + # auth (for example openai-codex). _explicit = (resolved_provider or "").strip().lower() if _explicit and _explicit not in {"auto", "openrouter", "custom"}: - raise RuntimeError( - f"Provider '{_explicit}' is set in config.yaml but no API key " - f"was found. Set the {_explicit.upper()}_API_KEY environment " - f"variable, or switch to a different provider with `hermes model`." + fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client( + task, _explicit, ) + if fb_client is not None: + client, final_model = fb_client, fb_model + resolved_provider = fb_label or resolved_provider + else: + raise RuntimeError( + f"Provider '{_explicit}' is set in config.yaml but no API key " + f"was found. Set the {_explicit.upper()}_API_KEY environment " + f"variable, or switch to a different provider with `hermes model`." + ) # For auto/custom with no credentials, try the full auto chain # rather than hardcoding OpenRouter (which may be depleted). # Pass model=None so each provider uses its own default — # resolved_model may be an OpenRouter-format slug that doesn't # work on other providers. - if not resolved_base_url: + if client is None and not resolved_base_url: logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", task or "call", resolved_provider) client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task) @@ -5657,6 +5857,7 @@ def call_llm( _is_payment_error(first_err) or _is_connection_error(first_err) or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) ) # Respect explicit provider choice for transient errors (auth, request # validation, etc.) but allow fallback when the provider clearly cannot @@ -5667,7 +5868,19 @@ def call_llm( is_auto = resolved_provider in {"auto", "", None} # Capacity errors bypass the explicit-provider gate: the provider # literally cannot serve this request regardless of user intent. - is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + # Rate limits are included: after retries are exhausted, a 429 means + # the provider cannot serve this request — fall back. See #52228. + # Model-incompatibility 400s are also a hard capability mismatch (the + # route cannot run this model at all — e.g. a codex/ChatGPT-account + # fallback asked to compress a glm-5.2 conversation), so they bypass + # the explicit-provider gate and continue to the next candidate + # instead of aborting the auxiliary task and churning the session. + is_capacity_error = ( + _is_payment_error(first_err) + or _is_connection_error(first_err) + or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) + ) if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" @@ -5680,6 +5893,8 @@ def call_llm( ) elif _is_rate_limit_error(first_err): reason = "rate limit" + elif _is_model_incompatible_error(first_err): + reason = "model incompatible with route" else: reason = "connection error" logger.info("Auxiliary %s: %s on %s (%s), trying fallback", @@ -5854,12 +6069,21 @@ async def async_call_llm( if client is None: _explicit = (resolved_provider or "").strip().lower() if _explicit and _explicit not in {"auto", "openrouter", "custom"}: - raise RuntimeError( - f"Provider '{_explicit}' is set in config.yaml but no API key " - f"was found. Set the {_explicit.upper()}_API_KEY environment " - f"variable, or switch to a different provider with `hermes model`." + fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client( + task, _explicit, ) - if not resolved_base_url: + if fb_client is not None: + client, final_model = _to_async_client( + fb_client, fb_model or "", is_vision=(task == "vision") + ) + resolved_provider = fb_label or resolved_provider + else: + raise RuntimeError( + f"Provider '{_explicit}' is set in config.yaml but no API key " + f"was found. Set the {_explicit.upper()}_API_KEY environment " + f"variable, or switch to a different provider with `hermes model`." + ) + if client is None and not resolved_base_url: logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", task or "call", resolved_provider) client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task) @@ -6109,12 +6333,22 @@ async def async_call_llm( _is_payment_error(first_err) or _is_connection_error(first_err) or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) ) - # Capacity errors (payment/quota/connection) bypass the explicit-provider - # gate — the provider cannot serve the request regardless of user intent. + # Capacity errors (payment/quota/connection/rate-limit) bypass the + # explicit-provider gate — the provider cannot serve the request + # regardless of user intent. Rate limits are included: after retries + # are exhausted, a 429 means the provider is at capacity. See #52228. # See #26803: daily token quota must fall back like a 402 credit error. + # Model-incompatibility 400s (route cannot run this model at all) + # bypass the gate too — see the sync call_llm() path for rationale. is_auto = resolved_provider in {"auto", "", None} - is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + is_capacity_error = ( + _is_payment_error(first_err) + or _is_connection_error(first_err) + or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) + ) if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" @@ -6123,6 +6357,8 @@ async def async_call_llm( ) elif _is_rate_limit_error(first_err): reason = "rate limit" + elif _is_model_incompatible_error(first_err): + reason = "model incompatible with route" else: reason = "connection error" logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", diff --git a/agent/coding_context.py b/agent/coding_context.py index 944083fe1b6..78229bc4f55 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -83,6 +83,59 @@ _PROJECT_MARKERS = ( # Agent-instruction files surfaced separately from manifests in the snapshot. _CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules") +# Source-file extensions that make a git repo a *code* workspace even with no +# manifest. Without this, `git init` on a notes/writing/research folder (a huge +# non-coding use case) would flip the whole session into the coding posture just +# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`). +_CODE_EXTENSIONS = frozenset({ + ".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", + ".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h", + ".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs", + ".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl", + ".hs", ".clj", ".erl", ".pl", +}) + +# Dirs never worth scanning for the code check (deps/build/vcs/venv noise). +_CODE_SCAN_SKIP_DIRS = frozenset({ + ".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", + "target", ".next", ".turbo", "vendor", +}) + +# Bounded sweep: a code workspace reveals itself in the first handful of entries. +_CODE_SCAN_MAX_ENTRIES = 500 + + +def _has_code_files(root: Path) -> bool: + """Cheap, bounded check for source files in a repo's top two levels. + + Lets a git repo of loose scripts (no manifest) still read as a code + workspace while a bare notes/writing repo does not. Scans the root and its + immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats — + a handful of readdirs at session start, not a full walk. + """ + seen = 0 + stack = [(root, True)] + while stack: + directory, is_root = stack.pop() + try: + with os.scandir(directory) as entries: + for entry in entries: + seen += 1 + if seen > _CODE_SCAN_MAX_ENTRIES: + return False + name = entry.name + try: + if entry.is_file(): + if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS: + return True + elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."): + stack.append((Path(entry.path), False)) + except OSError: + continue + except OSError: + continue + return False + # Lockfile → package manager, checked in priority order. _PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv")) _JS_LOCKFILES = ( @@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str: if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS: return GENERAL_PROFILE.name cwd = Path(cwd_str) + # A recognized project root (manifest / AGENTS.md / .cursorrules) is a code + # workspace on its own — cheap stat checks, no scan. + if _marker_root(cwd) is not None: + return CODING_PROFILE.name git_root = _git_root(cwd) if git_root is not None and git_root == _home(): git_root = None # dotfiles repo at $HOME — not a code workspace - if git_root is not None or _marker_root(cwd) is not None: + # A bare git repo only counts when it actually holds code, so `git init` on a + # notes/writing/research folder stays in the general posture. + if git_root is not None and _has_code_files(git_root): return CODING_PROFILE.name return GENERAL_PROFILE.name diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 5f9dcfa2e0d..fbde99bda5f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -890,7 +890,15 @@ class ContextCompressor(ContextEngine): # This is independent of the abort_on_summary_failure config flag: # rotating on a broken credential is never the right behavior. self._last_summary_auth_failure: bool = False - # When a user-configured summary model fails and we recover by + # Set when summary generation ultimately fails due to a transient + # network/connection error (httpx/httpcore connection drop, premature + # stream close, etc.) — distinct from auth failures but treated the + # same way by compress(): ABORT and preserve the session unchanged + # rather than destroy the middle window for a deterministic + # "summary unavailable" marker. Retrying once the network recovers is + # strictly better than discarding context for a transient blip + # (#29559, #25585). Independent of abort_on_summary_failure. + self._last_summary_network_failure: bool = False # retrying on the main model, record the failure so gateway / # CLI callers can still warn the user even though compression # succeeded. Silent recovery would hide the broken config. @@ -1687,6 +1695,7 @@ This compaction should PRIORITISE preserving all information related to the focu self._summary_model_fallen_back = False self._last_summary_error = None self._last_summary_auth_failure = False + self._last_summary_network_failure = False return self._with_summary_prefix(summary) except Exception as e: # ``call_llm`` raises ``RuntimeError`` for two very different cases: @@ -1819,6 +1828,15 @@ This compaction should PRIORITISE preserving all information related to the focu if len(err_text) > 220: err_text = err_text[:217].rstrip() + "..." self._last_summary_error = err_text + # A terminal connection/network failure (we reach this branch only + # after any main-model fallback has already been tried or is + # unavailable). Flag it so compress() ABORTS and preserves the + # session unchanged instead of destroying the middle window for a + # placeholder marker — retrying once the network recovers is + # strictly better than dropping context (#29559, #25585). Mirrors + # the auth-failure carve-out; independent of abort_on_summary_failure. + if _is_streaming_closed: + self._last_summary_network_failure = True logger.warning( "Failed to generate context summary: %s. " "Further summary attempts paused for %d seconds.", @@ -2382,6 +2400,7 @@ This compaction should PRIORITISE preserving all information related to the focu self._last_aux_model_failure_model = None self._last_compress_aborted = False self._last_summary_auth_failure = False + self._last_summary_network_failure = False # Manual /compress (force=True) bypasses the failure cooldown so the # user can retry immediately after an auto-compress abort. Without @@ -2498,15 +2517,21 @@ This compaction should PRIORITISE preserving all information related to the focu # surface a warning. # Default is False (historical behavior). # - # EXCEPTION — auth failures always abort. A 401/403 from the summary - # call means the credential or endpoint is broken (invalid/blocked - # key, or a token pointed at the wrong inference host). Rotating into + # EXCEPTION — auth AND transient network failures always abort. A + # 401/403 from the summary call means the credential or endpoint is + # broken (invalid/blocked key, or a token pointed at the wrong + # inference host). A connection/stream-close error means the network + # blipped at the compaction moment (#29559). In BOTH cases rotating into # a child session with a placeholder summary on a broken credential # strands the user on a degraded session for zero benefit — every # subsequent call fails the same way. So when the failure was an auth # error we abort regardless of abort_on_summary_failure, preserving # the conversation unchanged until the credential is fixed. - if not summary and (self.abort_on_summary_failure or self._last_summary_auth_failure): + if not summary and ( + self.abort_on_summary_failure + or self._last_summary_auth_failure + or self._last_summary_network_failure + ): n_skipped = compress_end - compress_start self._last_summary_dropped_count = 0 # nothing actually dropped self._last_summary_fallback_used = False @@ -2521,6 +2546,15 @@ This compaction should PRIORITISE preserving all information related to the focu "with /compress or start fresh with /new.", n_skipped, ) + elif self._last_summary_network_failure: + logger.warning( + "Summary generation failed with a network/connection " + "error — aborting compression. %d message(s) preserved " + "unchanged; the session was NOT rotated. This is " + "transient: retry with /compress once connectivity " + "recovers, or continue the conversation as-is.", + n_skipped, + ) else: logger.warning( "Summary generation failed — aborting compression " diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index ba67f036954..70d997631bc 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -90,6 +90,7 @@ def check_compression_model_feasibility(agent: Any) -> None: try: from agent.auxiliary_client import ( _resolve_task_provider_model, + _try_configured_fallback_for_unavailable_client, get_text_auxiliary_client, ) from agent.model_metadata import ( @@ -97,10 +98,6 @@ def check_compression_model_feasibility(agent: Any) -> None: get_model_context_length, ) - client, aux_model = get_text_auxiliary_client( - "compression", - main_runtime=agent._current_main_runtime(), - ) # Best-effort aux provider label for the warning message. The # configured provider may be "auto", in which case we fall back # to the client's base_url hostname so the user can still tell @@ -109,6 +106,19 @@ def check_compression_model_feasibility(agent: Any) -> None: _aux_cfg_provider, _, _, _, _ = _resolve_task_provider_model("compression") except Exception: _aux_cfg_provider = "" + client, aux_model = get_text_auxiliary_client( + "compression", + main_runtime=agent._current_main_runtime(), + ) + if client is None or not aux_model: + fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client( + "compression", + _aux_cfg_provider, + ) + if fb_client is not None and fb_model: + client, aux_model = fb_client, fb_model + if "(" in fb_label and fb_label.endswith(")"): + _aux_cfg_provider = fb_label.rsplit("(", 1)[1][:-1] if client is None or not aux_model: if _aux_cfg_provider and _aux_cfg_provider != "auto": msg = ( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bbc379adf25..23b30d92ec3 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -35,6 +35,7 @@ from agent.turn_context import build_turn_context from agent.turn_retry_state import TurnRetryState from agent.memory_manager import build_memory_context_block from agent.message_sanitization import ( + close_interrupted_tool_sequence, _repair_tool_call_arguments, _sanitize_messages_non_ascii, _sanitize_messages_surrogates, @@ -55,7 +56,7 @@ from agent.model_metadata import ( ) from agent.process_bootstrap import _install_safe_stdio from agent.prompt_caching import apply_anthropic_cache_control -from agent.retry_utils import jittered_backoff +from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff from agent.trajectory import has_incomplete_scratchpad from agent.usage_pricing import estimate_usage_cost, normalize_usage from hermes_constants import PARTIAL_STREAM_STUB_ID @@ -501,6 +502,7 @@ def run_conversation( stream_callback: Optional[callable] = None, persist_user_message: Optional[str] = None, persist_user_timestamp: Optional[float] = None, + moa_config: Optional[dict[str, Any]] = None, ) -> Dict[str, Any]: """ Run a complete conversation with tool calling until completion. @@ -523,6 +525,19 @@ def run_conversation( Returns: Dict: Complete conversation result with final response and message history """ + if moa_config is None: + try: + from hermes_cli.moa_config import decode_moa_turn + + _decoded_message, _decoded_moa_config = decode_moa_turn(user_message) + if _decoded_moa_config is not None: + user_message = _decoded_message + moa_config = _decoded_moa_config + if persist_user_message is None: + persist_user_message = _decoded_message + except Exception: + pass + # ── Per-turn setup (the prologue) ── # All once-per-turn setup — stdio guarding, retry-counter resets, user # message sanitization, todo/nudge hydration, system-prompt restore-or- @@ -801,6 +816,29 @@ def run_conversation( if effective_system: api_messages = [{"role": "system", "content": effective_system}] + api_messages + if moa_config: + try: + from agent.moa_loop import aggregate_moa_context + + _moa_context = aggregate_moa_context( + user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message), + api_messages=api_messages, + reference_models=moa_config.get("reference_models") or [], + aggregator=moa_config.get("aggregator") or {}, + temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6), + aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4), + max_tokens=int(moa_config.get("max_tokens", 4096) or 4096), + ) + if _moa_context: + for _msg in reversed(api_messages): + if _msg.get("role") == "user": + _base = _msg.get("content", "") + if isinstance(_base, str): + _msg["content"] = _base + "\n\n" + _moa_context + break + except Exception as _moa_exc: + logger.warning("MoA context aggregation failed: %s", _moa_exc) + # Inject ephemeral prefill messages right after the system prompt # but before conversation history. Same API-call-time-only pattern. if agent.prefill_messages: @@ -1122,7 +1160,7 @@ def run_conversation( # stream. Mirror the ACP exclusion used for Responses # API upgrade (lines ~1083-1085). elif ( - agent.provider == "copilot-acp" + agent.provider in {"copilot-acp", "moa"} or str(agent.base_url or "").lower().startswith("acp://copilot") or str(agent.base_url or "").lower().startswith("acp+tcp://") ): @@ -1396,10 +1434,12 @@ def run_conversation( while time.time() < sleep_end: if agent._interrupt_requested: agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True) + _interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})." + close_interrupted_tool_sequence(messages, _interrupt_text) agent._persist_session(messages, conversation_history) agent.clear_interrupt() return { - "final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).", + "final_response": _interrupt_text, "messages": messages, "api_calls": api_call_count, "completed": False, @@ -2663,10 +2703,12 @@ def run_conversation( # Check for interrupt before deciding to retry if agent._interrupt_requested: agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True) + _interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})." + close_interrupted_tool_sequence(messages, _interrupt_text) agent._persist_session(messages, conversation_history) agent.clear_interrupt() return { - "final_response": f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))}).", + "final_response": _interrupt_text, "messages": messages, "api_calls": api_call_count, "completed": False, @@ -3537,16 +3579,38 @@ def run_conversation( except (TypeError, ValueError): pass wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0) + _backoff_policy = None + if is_rate_limited and not _retry_after: + wait_time, _backoff_policy = adaptive_rate_limit_backoff( + retry_count, + base_url=str(_base), + model=_model, + error=api_error, + default_wait=wait_time, + ) if is_rate_limited: - agent._buffer_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...") + _policy_note = "" + if _backoff_policy == "zai_coding_overload_long": + _policy_note = " (Z.AI Coding overload adaptive long backoff)" + elif _backoff_policy == "zai_coding_overload_short": + _policy_note = " (Z.AI Coding overload short retry)" + _rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..." + # Normal retries are buffered to avoid noisy transient chatter. Long + # Z.AI Coding waits are different: they can last minutes, so surface + # progress immediately instead of making the TUI look frozen. + if _backoff_policy == "zai_coding_overload_long": + agent._emit_status(_rate_limit_status) + else: + agent._buffer_status(_rate_limit_status) else: agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...") logger.warning( - "Retrying API call in %ss (attempt %s/%s) %s error=%s", + "Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s", wait_time, retry_count, max_retries, agent._client_log_context(), + _backoff_policy or "default", api_error, ) # Sleep in small increments so we can respond to interrupts quickly @@ -3556,10 +3620,12 @@ def run_conversation( while time.time() < sleep_end: if agent._interrupt_requested: agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True) + _interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})." + close_interrupted_tool_sequence(messages, _interrupt_text) agent._persist_session(messages, conversation_history) agent.clear_interrupt() return { - "final_response": f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries}).", + "final_response": _interrupt_text, "messages": messages, "api_calls": api_call_count, "completed": False, @@ -4050,6 +4116,19 @@ def run_conversation( messages.append(assistant_msg) agent._emit_interim_assistant_message(assistant_msg) + try: + # Persist the assistant tool-call turn before any tool + # side effects run. If a destructive tool restarts or + # terminates Hermes mid-turn, resume logic still sees the + # exact tool-call block that already executed. + agent._flush_messages_to_session_db(messages, conversation_history) + except Exception as exc: + logger.warning( + "Incremental tool-call persistence failed before execution " + "(session=%s): %s", + agent.session_id or "none", + exc, + ) # Close any open streaming display (response box, reasoning # box) before tool execution begins. Intermediate turns may @@ -4479,9 +4558,10 @@ def run_conversation( final_msg = agent._build_assistant_message(assistant_message, finish_reason) # Pop thinking-only prefill and empty-response retry - # scaffolding before appending the final response. These - # internal turns are only for the next API retry and should - # not become durable transcript context. + # scaffolding before appending either a final response or a + # verification-stop follow-up. These internal turns are only + # for the next API retry and should not become durable + # transcript context. while ( messages and isinstance(messages[-1], dict) @@ -4493,6 +4573,44 @@ def run_conversation( ): messages.pop() + try: + from agent.verification_stop import ( + build_verify_on_stop_nudge, + verify_on_stop_enabled, + ) + + if verify_on_stop_enabled(): + _verify_nudge = build_verify_on_stop_nudge( + session_id=getattr(agent, "session_id", None), + changed_paths=getattr(agent, "_turn_file_mutation_paths", set()), + attempts=getattr(agent, "_verification_stop_nudges", 0), + ) + else: + _verify_nudge = None + except Exception: + logger.debug("verification stop-loop check failed", exc_info=True) + _verify_nudge = None + + if _verify_nudge: + agent._verification_stop_nudges = ( + getattr(agent, "_verification_stop_nudges", 0) + 1 + ) + final_msg["finish_reason"] = "verification_required" + messages.append(final_msg) + # Keep the attempted final answer in model history so the + # synthetic user nudge preserves role alternation, but do + # not surface it to the user as an interim answer. The + # whole point of this guard is to prevent premature + # "done" claims before checks run. + messages.append({ + "role": "user", + "content": _verify_nudge, + "_verification_stop_synthetic": True, + }) + agent._session_messages = messages + agent._emit_status("↻ Verification required before finishing") + continue + messages.append(final_msg) _turn_exit_reason = f"text_response(finish_reason={finish_reason})" diff --git a/agent/display.py b/agent/display.py index 01267e91ea1..77d832e91c3 100644 --- a/agent/display.py +++ b/agent/display.py @@ -6,6 +6,7 @@ Used by AIAgent._execute_tool_calls for CLI feedback. import logging import os +import re import sys import threading import time @@ -177,6 +178,167 @@ def _truncate_preview(text: str, max_len: int | None) -> str: return text +_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"} +_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"} + + +def _shell_basename(head: str) -> str: + return head.rsplit("/", 1)[-1] if head else "" + + +def _split_shell_words(segment: str) -> list[str]: + words: list[str] = [] + buf: list[str] = [] + quote: str | None = None + + for i, ch in enumerate(segment): + if quote: + buf.append(ch) + if ch == quote and (i == 0 or segment[i - 1] != "\\"): + quote = None + continue + + if ch in {"'", '"'}: + quote = ch + buf.append(ch) + continue + + if ch.isspace(): + if buf: + words.append("".join(buf)) + buf = [] + continue + + buf.append(ch) + + if buf: + words.append("".join(buf)) + + return words + + +def _strip_shell_pipe_tail(segment: str) -> str: + words = _split_shell_words(segment) + out: list[str] = [] + + for i, word in enumerate(words): + if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS: + break + out.append(word) + + return " ".join(out).strip() + + +def _split_shell_compound(command: str) -> list[str]: + segments: list[str] = [] + buf: list[str] = [] + quote: str | None = None + i = 0 + + while i < len(command): + ch = command[i] + + if quote: + buf.append(ch) + if ch == quote and (i == 0 or command[i - 1] != "\\"): + quote = None + i += 1 + continue + + if ch in {"'", '"'}: + quote = ch + buf.append(ch) + i += 1 + continue + + op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0 + if op_len: + segment = _strip_shell_pipe_tail("".join(buf).strip()) + if segment: + segments.append(segment) + buf = [] + i += op_len + continue + + buf.append(ch) + i += 1 + + segment = _strip_shell_pipe_tail("".join(buf).strip()) + if segment: + segments.append(segment) + + return segments + + +def _shell_head_word(segment: str) -> str: + words = _split_shell_words(segment) + index = 0 + while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]): + index += 1 + return _shell_basename(words[index] if index < len(words) else "") + + +def _clean_shell_segment(segment: str) -> str: + words = _split_shell_words(segment) + out: list[str] = [] + i = 0 + while i < len(words): + word = words[i] + if re.match(r"^\d*(?:>>?|<)$", word): + i += 2 + continue + if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word): + i += 1 + continue + out.append(word) + i += 1 + return " ".join(out).strip() + + +def _is_shell_boundary_echo(segment: str) -> bool: + words = _split_shell_words(segment) + if _shell_basename(words[0] if words else "") != "echo": + return False + rest = " ".join(words[1:]) + return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest)) + + +def summarize_shell_command(command: str) -> str: + """Compact shell wrapper/plumbing for display while preserving raw command elsewhere.""" + original = _oneline(command) + if not original: + return "" + + segments = _split_shell_compound(original) + if len(segments) <= 1: + return _clean_shell_segment(segments[0] if segments else original) or original + + core: list[str] = [] + for segment in segments: + cleaned = _clean_shell_segment(segment) + head = _shell_head_word(cleaned) + if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned): + core.append(cleaned) + + if not core: + return original + if len(core) == 1: + return core[0] + + count = len(core) - 1 + return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}" + + +def _read_file_line_label(args: dict) -> str: + offset = args.get("offset") + limit = args.get("limit") + if not isinstance(offset, int) or offset <= 0: + return "" + if not isinstance(limit, int) or limit <= 1: + return f"L{offset}" + return f"L{offset}-{offset + limit - 1}" + + def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]: if not isinstance(tasks, list): return 0, [] @@ -206,7 +368,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - "search_files": "pattern", "browser_navigate": "url", "browser_click": "ref", "browser_type": "text", "image_generate": "prompt", "text_to_speech": "text", - "vision_analyze": "question", "mixture_of_agents": "user_prompt", + "vision_analyze": "question", "skill_view": "name", "skills_list": "category", "cronjob": "action", "execute_code": "code", "delegate_task": "goal", @@ -253,6 +415,23 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - else: return f"planning {len(todos_arg)} task(s)" + if tool_name in {"terminal", "execute_code"}: + key = "code" if tool_name == "execute_code" else "command" + command = args.get(key) + if command is None: + return None + preview = summarize_shell_command(str(command)) + return _truncate_preview(preview, max_len) if preview else None + + if tool_name == "read_file": + path = args.get("path") or args.get("file") or args.get("filepath") + if path is None: + return None + label = Path(str(path).replace("\\", "/")).name or str(path) + line_label = _read_file_line_label(args) + preview = f"{label} {line_label}".strip() + return _truncate_preview(preview, max_len) if preview else None + if tool_name == "session_search": query = _oneline(args.get("query", "")) return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\"" @@ -943,7 +1122,7 @@ def get_cute_tool_message( return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") return _wrap(f"┊ 📄 fetch pages {dur}") if tool_name == "terminal": - return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}") + return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}") if tool_name == "process": action = args.get("action", "?") sid = args.get("session_id", "")[:12] @@ -951,7 +1130,7 @@ def get_cute_tool_message( "wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"} return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}") if tool_name == "read_file": - return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}") + return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}") if tool_name == "write_file": return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}") if tool_name == "patch": @@ -1037,8 +1216,6 @@ def get_cute_tool_message( return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}") if tool_name == "vision_analyze": return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}") - if tool_name == "mixture_of_agents": - return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}") if tool_name == "send_message": return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}") if tool_name == "cronjob": diff --git a/agent/learn_prompt.py b/agent/learn_prompt.py new file mode 100644 index 00000000000..64ad543f839 --- /dev/null +++ b/agent/learn_prompt.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""``/learn`` — build the standards-guided prompt that turns whatever the user +described into a reusable skill. + +``/learn`` is open-ended. The user can point it at anything they can describe: +a directory of code, an API doc URL, a workflow they just walked the agent +through in this conversation, or pasted notes. This module builds ONE prompt +that instructs the live agent to: + + 1. Gather the sources the user named, using the tools it already has + (``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the + current conversation for "what I just did", the user's text for pasted + material). + 2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes + skill-authoring standards (description <=60 chars, the modern section + order, Hermes-tool framing, no invented commands). + +There is no separate distillation engine and no model-tool footprint: the +agent does the work with its existing toolset, so this works identically on +local, Docker, and remote terminal backends. Every surface (CLI ``/learn``, +gateway ``/learn``, the dashboard "Learn a skill" panel) calls +:func:`build_learn_prompt` and feeds the result to the agent as a normal turn. +""" + +from __future__ import annotations + +# The house-style rules, distilled from AGENTS.md "Skill authoring standards +# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in +# the prompt so the agent authors skills the way a maintainer would by hand. +_AUTHORING_STANDARDS = """\ +Follow the Hermes skill-authoring standards exactly. These are the same +HARDLINE rules a maintainer enforces in review: + +Frontmatter: +- name: lowercase-hyphenated, <=64 chars, no spaces. +- description: ONE sentence, **<=60 characters**, ends with a period. State the + capability, not the implementation. No marketing words (powerful, + comprehensive, seamless, advanced, robust). Do NOT repeat the skill name. If + the description contains a colon, wrap the whole value in double quotes. + This is the most-violated rule and it is NOT cosmetic: the system-prompt + skill index truncates the description to 60 chars and loads it every + session, so anything past char 60 is silently cut and never routes. After + you write the description, COUNT the characters; if it is over 60, cut it + down before saving — do not ship a sentence and hope. + Good (<=60): `Search arXiv papers by keyword, author, or ID.` + Bad (123): `A comprehensive skill that lets the agent search arXiv for + academic papers using keywords, authors, and categories.` +- version: 0.1.0 +- author: always the literal value `Hermes`. NEVER fill it from the host + environment — the OS/login username (e.g. the `user=` line in your + environment hints), git config, or any identity you can probe must not be + written. Skills get shared and published, so an environment-derived name is + a privacy leak the user never opted into; the skill names itself as Hermes. +- platforms: declare `[macos]`, `[linux]`, and/or `[windows]` IF the skill + uses OS-bound primitives (osascript/apt/systemctl => the matching OS; /proc, + os.setsid, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it + cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only + when the dependency is genuinely platform-bound. Omit the field for portable + skills. +- metadata.hermes.tags: a few Capitalized, Relevant, Tags. + +Body section order (omit a section only if it genuinely has no content): +1. "# " then a 2-3 sentence intro: what it does, what it does NOT + do, and the key dependency stance (e.g. "stdlib only"). +2. "## When to Use" — bullet list of concrete trigger phrases. +3. "## Prerequisites" — exact env vars, install steps, credentials. +4. "## How to Run" — the canonical invocation, framed through Hermes tools. +5. "## Quick Reference" — a flat command/endpoint list, no narration. +6. "## Procedure" — numbered steps with copy-paste-exact commands. +7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't. +8. "## Verification" — a single command/check that proves the skill worked. + +Hermes-tool framing (this is what makes it a skill, not shell docs): +- Frame running scripts as "invoke through the `terminal` tool". +- Reference Hermes tools by name in backticks: `terminal`, `read_file`, + `write_file`, `search_files`, `patch`, `web_extract`, `web_search`, + `vision_analyze`, `browser_navigate`, `delegate_task`, `image_generate`, + `text_to_speech`, `cronjob`, `memory`, `skill_view`, `execute_code`. +- Do NOT name shell utilities the agent already has wrapped: say `read_file` + not cat/head/tail, `search_files` not grep/rg/find/ls, `patch` not sed/awk, + `web_extract` not curl-to-scrape, `write_file` not echo>file or heredocs. +- Third-party CLIs (ffmpeg, gh, an SDK) are fine inside a script file, but the + prose still frames them as "invoke through the `terminal` tool". If the + skill needs an MCP server, name it and document its setup in Prerequisites. + +Quality bar: +- Prefer exact commands, endpoint URLs, function signatures, and config keys + that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if + you didn't see it in the source, don't write it. +- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a + complex one. Don't re-paste the source docs. +- Don't write a router/index/hub skill that only points at other skills. +- Larger scripts/parsers belong in a `scripts/` file (add via + `skill_manage` write_file), referenced from SKILL.md by relative path — not + inlined for the agent to re-type every run. References go in `references/`, + templates in `templates/`.""" + + +def build_learn_prompt(user_request: str) -> str: + """Build the agent prompt for an open-ended ``/learn`` request. + + Args: + user_request: the free-text the user gave after ``/learn`` — a + description of the workflow, paths, URLs, or "what I just did". + + Returns: + A complete instruction the agent runs as a normal turn. The agent + gathers the described sources with its existing tools and authors the + skill via ``skill_manage``. + """ + req = (user_request or "").strip() + if not req: + req = ( + "the workflow we just went through in this conversation — review " + "the steps taken and distill them into a reusable skill" + ) + + return ( + "[/learn] The user wants you to learn a reusable skill from the " + "source(s) they described below, and save it.\n\n" + f"WHAT TO LEARN FROM:\n{req}\n\n" + "Do this:\n" + "1. Gather the material. Resolve whatever the user named using the " + "tools you already have — `read_file`/`search_files` for local files " + "or directories, `web_extract` for URLs, the current conversation " + "history if they referred to something you just did, and the text " + "they pasted as-is. If the request is ambiguous about scope, make a " + "reasonable choice and note it; do not stall.\n" + "2. Author ONE SKILL.md and save it with the `skill_manage` tool " + "(action=\"create\"). Pick a sensible category. If the procedure needs " + "a non-trivial script, add it under the skill's `scripts/` with " + "`skill_manage` write_file and reference it by relative path.\n\n" + f"{_AUTHORING_STANDARDS}\n\n" + "When done, tell the user the skill name, its category, and a " + "one-line summary of what it captured." + ) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index b24c76b3107..984499228fe 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -46,6 +46,39 @@ logger = logging.getLogger(__name__) _SYNC_DRAIN_TIMEOUT_S = 5.0 +def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]: + """Return a function-tool dict with a resolvable top-level ``name``. + + Context engines and memory providers expose tool schemas via + ``get_tool_schemas()``. The expected shape is a bare function schema + (``{"name": ..., "description": ..., "parameters": ...}``) which callers + wrap as ``{"type": "function", "function": schema}``. + + Some providers instead return an entry that is *already* in OpenAI tool + form (``{"type": "function", "function": {"name": ...}}``). Wrapping that + a second time produces ``{"type": "function", "function": {"type": + "function", "function": {...}}}`` whose ``function`` has no top-level + ``name``. Strict providers (e.g. DeepSeek) reject the *entire* request + with ``tools[N].function: missing field name`` (HTTP 400), so one bad + schema disables the whole toolset and breaks every turn (#47707). + + This helper normalizes both shapes to the bare function schema and + returns ``None`` for anything without a resolvable name, so callers can + skip-with-warning rather than appending a nameless tool. + """ + if not isinstance(schema, dict): + return None + # Unwrap an already-wrapped OpenAI tool entry. + if schema.get("type") == "function" and isinstance(schema.get("function"), dict): + schema = schema["function"] + if not isinstance(schema, dict): + return None + name = schema.get("name", "") + if not name or not isinstance(name, str): + return None + return schema + + def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool: """Return whether external memory-provider tools should be exposed.""" if enabled_toolsets is None: @@ -92,11 +125,17 @@ def inject_memory_provider_tools(agent: Any) -> int: agent.valid_tool_names = valid_tool_names added = 0 - for schema in get_schemas(): - if not isinstance(schema, dict): + for raw_schema in get_schemas(): + schema = normalize_tool_schema(raw_schema) + if schema is None: + logger.warning( + "Memory provider returned a tool schema with no resolvable " + "name; skipping to avoid poisoning the request (%r)", + raw_schema, + ) continue - tool_name = schema.get("name", "") - if not tool_name or tool_name in existing_tool_names: + tool_name = schema["name"] + if tool_name in existing_tool_names: continue tools.append({"type": "function", "function": schema}) valid_tool_names.add(tool_name) @@ -370,8 +409,11 @@ class MemoryManager: _core_tool_names = set(_HERMES_CORE_TOOLS) # Index tool names → provider for routing - for schema in provider.get_tool_schemas(): - tool_name = schema.get("name", "") + for raw_schema in provider.get_tool_schemas(): + schema = normalize_tool_schema(raw_schema) + if schema is None: + continue + tool_name = schema["name"] if tool_name in _core_tool_names: logger.warning( "Memory provider '%s' tool '%s' shadows a reserved core " @@ -658,11 +700,19 @@ class MemoryManager: seen = set() for provider in self._providers: try: - for schema in provider.get_tool_schemas(): - name = schema.get("name", "") + for raw_schema in provider.get_tool_schemas(): + schema = normalize_tool_schema(raw_schema) + if schema is None: + logger.warning( + "Memory provider '%s' returned a tool schema with " + "no resolvable name; skipping (%r)", + provider.name, raw_schema, + ) + continue + name = schema["name"] if name in _core_tool_names: continue - if name and name not in seen: + if name not in seen: schemas.append(schema) seen.add(name) except Exception as e: diff --git a/agent/message_sanitization.py b/agent/message_sanitization.py index ff53d247a84..29a4b8691ae 100644 --- a/agent/message_sanitization.py +++ b/agent/message_sanitization.py @@ -279,6 +279,38 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str: return "{}" +def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool: + """Append a synthetic assistant turn when an interrupted tail is a tool result. + + A turn cut short by ``/stop`` can leave the transcript ending on a raw + ``tool`` message (a tool finished, or its execution was cancelled, but the + model never streamed a closing assistant turn). Persisting that tail means + the next user message lands as ``… tool → user`` — a role-alternation + violation that strict providers (Gemini, Claude) react to by hallucinating + a continuation of the user's message and ignoring prior context, which + reads to the user as "lost context" (#48879). + + ``finalize_turn`` closes this on the happy interrupt path, but the + retry/backoff/error interrupt aborts in ``conversation_loop`` ``return`` + early and never reach it — this shared helper closes the sequence on all of + them. ``final_response`` is usually empty on an interrupt, so an explicit + placeholder is used rather than an empty-content assistant turn. + + Mutates ``messages`` in place. Returns True if a closing turn was appended. + """ + if not messages: + return False + last = messages[-1] + if not isinstance(last, dict) or last.get("role") != "tool": + return False + text = final_response if isinstance(final_response, str) else "" + messages.append({ + "role": "assistant", + "content": text.strip() or "Operation interrupted.", + }) + return True + + def _strip_non_ascii(text: str) -> str: """Remove non-ASCII characters, replacing with closest ASCII equivalent or removing. @@ -431,6 +463,7 @@ def _sanitize_structure_non_ascii(payload: Any) -> bool: __all__ = [ "_SURROGATE_RE", + "close_interrupted_tool_sequence", "_sanitize_surrogates", "_sanitize_structure_surrogates", "_sanitize_messages_surrogates", diff --git a/agent/moa_loop.py b/agent/moa_loop.py new file mode 100644 index 00000000000..f908c70a0a5 --- /dev/null +++ b/agent/moa_loop.py @@ -0,0 +1,306 @@ +"""Mixture-of-Agents runtime helpers for /moa turns. + +The slash command is deliberately not a model tool. It marks one user turn as +MoA-enabled; the normal Hermes agent loop still owns tool calling and turn +termination, while this module gathers reference-model context before each model +iteration. +""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +from agent.auxiliary_client import call_llm +from agent.transports import get_transport + +logger = logging.getLogger(__name__) + +# Upper bound on concurrent reference-model calls. References are independent +# advisory calls (no tools, no inter-dependence), so we fan them out the same +# way delegate_task runs a batch: all in flight at once, results collected when +# every reference finishes. Presets rarely list more than a handful of +# references; this cap just protects against a pathologically large preset +# opening dozens of sockets at once. +_MAX_REFERENCE_WORKERS = 8 + + +def _slot_label(slot: dict[str, str]) -> str: + return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}" + + +def _run_reference( + slot: dict[str, str], + ref_messages: list[dict[str, Any]], + *, + temperature: float, + max_tokens: int, +) -> tuple[str, str]: + """Call one reference model and return ``(label, text)``. + + Never raises: a failed reference becomes a labelled note so the aggregator + can still act with partial context. Designed to run inside a thread pool — + ``call_llm`` is synchronous/blocking, so threads (not asyncio) are the right + concurrency primitive, mirroring ``delegate_task``'s batch fan-out. + """ + label = _slot_label(slot) + try: + response = call_llm( + task="moa_reference", + provider=slot["provider"], + model=slot["model"], + messages=ref_messages, + temperature=temperature, + max_tokens=max_tokens, + ) + return label, _extract_text(response) or "(empty response)" + except Exception as exc: + logger.warning("MoA reference model %s failed: %s", label, exc) + return label, f"[failed: {exc}]" + + +def _run_references_parallel( + reference_models: list[dict[str, str]], + ref_messages: list[dict[str, Any]], + *, + temperature: float, + max_tokens: int, +) -> list[tuple[str, str]]: + """Fan out all reference models in parallel, returning outputs in order. + + Like ``delegate_task``'s batch mode, every reference is dispatched at once + and we block until all of them finish before handing the joined results to + the aggregator. Output order matches ``reference_models`` so the + ``Reference {idx}`` labelling stays stable. MoA presets that reference + another MoA preset are skipped here (recursion guard) with a labelled note. + """ + if not reference_models: + return [] + + results: list[tuple[str, str] | None] = [None] * len(reference_models) + futures = {} + workers = min(_MAX_REFERENCE_WORKERS, len(reference_models)) + with ThreadPoolExecutor(max_workers=workers) as executor: + for idx, slot in enumerate(reference_models): + if slot.get("provider") == "moa": + results[idx] = ( + _slot_label(slot), + "[skipped: MoA presets cannot recursively reference MoA]", + ) + continue + futures[ + executor.submit( + _run_reference, + slot, + ref_messages, + temperature=temperature, + max_tokens=max_tokens, + ) + ] = idx + # Collect every reference before returning — the aggregator needs the + # complete set, so there is no early-exit / first-completed path here. + for future, idx in futures.items(): + results[idx] = future.result() + + return [r for r in results if r is not None] + + +def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Build an advisory-safe view of the conversation for reference models. + + Reference calls are advisory: they never call tools and never emit the + ``tool_calls`` the main model did. Replaying the full transcript verbatim + (a) re-bills the ~8K-token Hermes system prompt per reference per + iteration and (b) risks 400s from strict providers (Mistral, Fireworks) + that reject orphan ``tool`` messages or ``tool_calls`` the reference never + produced. We keep only the user/assistant *text* turns, dropping the + system prompt, any ``tool``-role messages, and any ``tool_calls`` payloads. + """ + trimmed: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + if role not in ("user", "assistant"): + # Drop system prompt and tool-result messages. + continue + content = msg.get("content") + if not isinstance(content, str): + # Skip non-text (multimodal/tool-call-only) assistant turns. + if not content: + continue + text = content if isinstance(content, str) else "" + if role == "assistant" and not text.strip(): + # Assistant turn that was purely tool calls — nothing advisory. + continue + trimmed.append({"role": role, "content": text}) + if not trimmed: + # Degenerate case (e.g. first turn was stripped): fall back to a + # minimal user turn so the reference still has something to answer. + for msg in reversed(messages): + if msg.get("role") == "user" and isinstance(msg.get("content"), str): + return [{"role": "user", "content": msg["content"]}] + return trimmed + + + +def _extract_text(response: Any) -> str: + try: + transport = get_transport("chat_completions") + if transport is None: + raise RuntimeError("chat_completions transport unavailable") + normalized = transport.normalize_response(response) + text = (normalized.content or "").strip() + if text: + return text + except Exception: + pass + try: + content = response.choices[0].message.content + return (content or "").strip() + except Exception: + return "" + + +def aggregate_moa_context( + *, + user_prompt: str, + api_messages: list[dict[str, Any]], + reference_models: list[dict[str, str]], + aggregator: dict[str, str], + temperature: float = 0.6, + aggregator_temperature: float = 0.4, + max_tokens: int = 4096, +) -> str: + """Run configured reference models and synthesize their advice. + + Failures are returned as model-specific notes instead of aborting the normal + agent loop; the main model can still act with partial context. + """ + reference_outputs: list[tuple[str, str]] = [] + ref_messages = _reference_messages(api_messages) + reference_outputs = _run_references_parallel( + reference_models, + ref_messages, + temperature=temperature, + max_tokens=max_tokens, + ) + + joined = "\n\n".join( + f"Reference {idx} — {label}:\n{text}" + for idx, (label, text) in enumerate(reference_outputs, start=1) + ) + synth_prompt = ( + "You are the aggregator in a Mixture of Agents process. Synthesize the " + "reference responses into concise, actionable guidance for the main " + "Hermes agent. Focus on next steps, tool-use strategy, risks, and any " + "disagreements. Do not answer the user directly unless that is all that " + "is needed; produce context the main agent should use in its normal loop.\n\n" + f"Original user prompt:\n{user_prompt}\n\n" + f"Reference responses:\n{joined}" + ) + + agg_label = _slot_label(aggregator) + try: + response = call_llm( + task="moa_aggregator", + provider=aggregator["provider"], + model=aggregator["model"], + messages=[{"role": "user", "content": synth_prompt}], + temperature=aggregator_temperature, + max_tokens=max_tokens, + ) + synthesis = _extract_text(response) + except Exception as exc: + logger.warning("MoA aggregator model %s failed: %s", agg_label, exc) + synthesis = "" + + if not synthesis: + synthesis = joined + + return ( + "[Mixture of Agents context — use this as private guidance for the " + "normal Hermes agent loop. You may call tools, continue reasoning, or " + "finish normally.]\n" + f"Aggregator: {agg_label}\n" + f"References: {', '.join(_slot_label(slot) for slot in reference_models)}\n\n" + f"{synthesis.strip()}" + ) + + +class MoAChatCompletions: + """OpenAI-chat-compatible facade where the aggregator is the acting model.""" + + def __init__(self, preset_name: str): + self.preset_name = preset_name or "default" + + def create(self, **api_kwargs: Any) -> Any: + from hermes_cli.config import load_config + from hermes_cli.moa_config import resolve_moa_preset + + preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name) + messages = list(api_kwargs.get("messages") or []) + reference_models = preset.get("reference_models") or [] + aggregator = preset.get("aggregator") or {} + max_tokens = int(preset.get("max_tokens", api_kwargs.get("max_tokens") or 4096) or 4096) + temperature = float(preset.get("reference_temperature", 0.6) or 0.6) + aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4) + + # When the preset is disabled, skip the reference fan-out and let the + # configured aggregator act alone — it is the preset's acting model, so + # a disabled MoA preset is simply "use the aggregator directly." + if not preset.get("enabled", True): + reference_models = [] + + reference_outputs: list[tuple[str, str]] = [] + ref_messages = _reference_messages(messages) + reference_outputs = _run_references_parallel( + reference_models, + ref_messages, + temperature=temperature, + max_tokens=max_tokens, + ) + + agg_messages = [dict(m) for m in messages] + if reference_outputs: + joined = "\n\n".join( + f"Reference {idx} — {label}:\n{text}" + for idx, (label, text) in enumerate(reference_outputs, start=1) + ) + guidance = ( + "[Mixture of Agents reference context]\n" + f"Preset: {self.preset_name}\n" + f"Aggregator/acting model: {_slot_label(aggregator)}\n" + f"References: {', '.join(label for label, _ in reference_outputs)}\n\n" + "Use the reference responses below as private context. You are the aggregator and acting model: " + "answer the user directly or call tools as needed.\n\n" + f"{joined}" + ) + for msg in reversed(agg_messages): + if msg.get("role") == "user" and isinstance(msg.get("content"), str): + msg["content"] = msg["content"] + "\n\n" + guidance + break + else: + agg_messages.append({"role": "user", "content": guidance}) + + if aggregator.get("provider") == "moa": + raise RuntimeError("MoA aggregator cannot be another MoA preset") + agg_kwargs = dict(api_kwargs) + agg_kwargs["messages"] = agg_messages + agg_kwargs["model"] = aggregator.get("model") + agg_kwargs["temperature"] = aggregator_temperature + return call_llm( + task="moa_aggregator", + provider=aggregator.get("provider"), + model=aggregator.get("model"), + messages=agg_messages, + temperature=aggregator_temperature, + max_tokens=agg_kwargs.get("max_tokens"), + tools=agg_kwargs.get("tools"), + extra_body=agg_kwargs.get("extra_body"), + ) + + +class MoAClient: + def __init__(self, preset_name: str): + self.chat = type("_MoAChat", (), {})() + self.chat.completions = MoAChatCompletions(preset_name) diff --git a/agent/pet/__init__.py b/agent/pet/__init__.py new file mode 100644 index 00000000000..b045598d2eb --- /dev/null +++ b/agent/pet/__init__.py @@ -0,0 +1,51 @@ +"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces. + +Petdex (https://github.com/crafter-station/petdex) is a public gallery of +animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a +``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use +an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas. +Hermes infers the row taxonomy from the sheet and maps agent activity onto +idle/run/review/failed/wave/jump. + +This package is the **single source of truth** for the feature so the base +CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard +parts: + +- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum. +- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`. +- :mod:`agent.pet.manifest` — fetch the public petdex manifest. +- :mod:`agent.pet.store` — install / list / resolve pets on disk + (profile-aware via ``get_hermes_home()``). +- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a + terminal (kitty / iTerm2 / sixel graphics + protocols, with a Unicode half-block + fallback). + +Rendering in the Electron desktop is necessarily TypeScript (canvas), but it +reuses the same on-disk store and the same state semantics. + +The whole feature is a *display* concern: it adds no model tool, mutates no +system prompt or toolset, and therefore has zero effect on prompt caching. +""" + +from agent.pet.constants import ( + DEFAULT_SCALE, + FRAME_H, + FRAME_W, + FRAMES_PER_STATE, + LOOP_MS, + STATE_ROWS, + PetState, +) +from agent.pet.state import derive_pet_state + +__all__ = [ + "DEFAULT_SCALE", + "FRAME_H", + "FRAME_W", + "FRAMES_PER_STATE", + "LOOP_MS", + "STATE_ROWS", + "PetState", + "derive_pet_state", +] diff --git a/agent/pet/constants.py b/agent/pet/constants.py new file mode 100644 index 00000000000..a7e816c4012 --- /dev/null +++ b/agent/pet/constants.py @@ -0,0 +1,167 @@ +"""Pet sprite geometry + animation-state taxonomy. + +These values are the common petdex/Codex pet geometry. The real ``pet.json`` +usually only carries ``id``/``displayName``/``description``/``spritesheetPath``; +row taxonomy is inferred from the atlas shape so Hermes can render both legacy +8-row sheets and current 9-row Codex sheets. +""" + +from __future__ import annotations + +from enum import Enum + +# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9 +# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows +# (1728x1664). Renderers derive both row taxonomy and real column count from the +# concrete sheet, so either shape works. +FRAME_W = 192 +FRAME_H = 208 + +# Frames consumed per animation state (the petdex web app uses CSS +# ``steps(6)``). A sheet may physically contain more columns; we only step +# through the first ``FRAMES_PER_STATE``. +FRAMES_PER_STATE = 6 + +# Full-loop duration for one state, milliseconds (petdex default). +LOOP_MS = 1100 + +# Default on-screen scale relative to native frame size. ``display.pet.scale`` +# is the single master scalar: the desktop canvas multiplies its native pixels +# by it and every terminal surface derives its half-block/kitty column width +# from it (see :func:`cols_for_scale`), so one number shrinks all three +# interfaces together. (petdex's own clients render at 0.7; we default smaller +# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block +# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its +# legibility floor instead.) +DEFAULT_SCALE = 0.33 + +# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the +# pet clickable/visible; ceiling stops a fat-fingered value from filling the +# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``. +MIN_SCALE = 0.1 +MAX_SCALE = 3.0 + + +def clamp_scale(scale: float) -> float: + """Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point).""" + return max(MIN_SCALE, min(MAX_SCALE, scale)) + +# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px +# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty +# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees. +BASE_UNICODE_COLS = FRAME_W // 8 + +# Legibility floor for the half-block fallback. A half-block cell samples the +# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208 +# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw +# true pixels and have no such floor — that's why the same ``scale: 0.33`` is +# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down +# TO this floor (and grows it above), instead of past it into noise. +UNICODE_MIN_COLS = 16 + + +def cols_for_scale(scale: float) -> int: + """Half-block width implied by *scale*, clamped to the legibility floor. + + Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two + renderers converge at larger sizes; below it the floor keeps the sprite + readable rather than letting it devolve into a blob. + """ + return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE))) + + +def resolve_cols(scale: float, unicode_cols: int = 0) -> int: + """Resolve terminal width: explicit *unicode_cols* override, else from *scale*.""" + return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale) + + +class PetState(str, Enum): + """Animation state a pet can be shown in. + + These are Hermes' activity state names. They are not always identical to the + source atlas row names: Codex-format pets use rows like ``jumping`` / + ``running`` while the UI keeps the shorter ``jump`` / ``run`` names. + """ + + IDLE = "idle" + WAVE = "wave" + RUN = "run" + FAILED = "failed" + REVIEW = "review" + JUMP = "jump" + WAITING = "waiting" + + +# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row, +# 9-column atlas shape. +LEGACY_STATE_ROWS: list[str] = [ + PetState.IDLE.value, + PetState.WAVE.value, + PetState.RUN.value, + PetState.FAILED.value, + PetState.REVIEW.value, + PetState.JUMP.value, + "extra1", + "extra2", +] + +# Current Petdex row order (top -> bottom) used by 1536x1872 atlases: +# 8 columns x 9 rows of 192x208 cells. +CODEX_STATE_ROWS: list[str] = [ + PetState.IDLE.value, + "running-right", + "running-left", + "waving", + "jumping", + PetState.FAILED.value, + PetState.WAITING.value, + "running", + PetState.REVIEW.value, +] + +# Default/fallback for callers without a sheet. Prefer the current 9-row Codex +# format because generated pets and the public Codex pet contract use it. +STATE_ROWS: list[str] = CODEX_STATE_ROWS + +# Canonical Hermes activity names -> accepted row-name aliases in descending +# preference. This keeps our internal state names stable (`wave`/`jump`/`run`) +# while matching Petdex's current `waving`/`jumping`/`running` taxonomy. +STATE_ALIASES: dict[str, tuple[str, ...]] = { + PetState.IDLE.value: (PetState.IDLE.value,), + PetState.WAVE.value: (PetState.WAVE.value, "waving"), + PetState.JUMP.value: (PetState.JUMP.value, "jumping"), + PetState.RUN.value: (PetState.RUN.value, "running"), + PetState.FAILED.value: (PetState.FAILED.value,), + PetState.REVIEW.value: (PetState.REVIEW.value,), + PetState.WAITING.value: (PetState.WAITING.value,), +} + + +def state_aliases_for(state: "PetState | str") -> tuple[str, ...]: + """Return accepted row-name aliases for *state* (always non-empty).""" + value = state.value if isinstance(state, PetState) else str(state) + aliases = STATE_ALIASES.get(value) + return aliases if aliases else (value,) + + +def state_rows_for_grid(row_count: int | None) -> list[str]: + """Return the row taxonomy for a spritesheet with *row_count* rows.""" + try: + rows = int(row_count or 0) + except (TypeError, ValueError): + rows = 0 + + if rows >= len(CODEX_STATE_ROWS): + return CODEX_STATE_ROWS + return LEGACY_STATE_ROWS + + +def state_row_index(state: "PetState | str", row_count: int | None = None) -> int: + """Return the spritesheet row index for *state* (clamped, never raises).""" + rows = state_rows_for_grid(row_count) + for name in state_aliases_for(state): + try: + return rows.index(name) + except ValueError: + continue + return 0 # fall back to the idle row diff --git a/agent/pet/generate/__init__.py b/agent/pet/generate/__init__.py new file mode 100644 index 00000000000..b75a03cd985 --- /dev/null +++ b/agent/pet/generate/__init__.py @@ -0,0 +1,29 @@ +"""Pet generation — base-draft → hatch pipeline. + +Public surface used by the gateway RPCs, the CLI ``hermes pets generate`` +command, and tests: + +- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow. +- :class:`HatchResult`, :class:`GenerationError`. +- :mod:`atlas` — deterministic frame extraction + atlas composition/validation. + +Image generation is delegated to the active reference-capable +:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea); +atlas assembly is fully deterministic so it's testable without any API calls. +""" + +from __future__ import annotations + +from agent.pet.generate.imagegen import GenerationError +from agent.pet.generate.orchestrate import ( + HatchResult, + generate_base_drafts, + hatch_pet, +) + +__all__ = [ + "GenerationError", + "HatchResult", + "generate_base_drafts", + "hatch_pet", +] diff --git a/agent/pet/generate/atlas.py b/agent/pet/generate/atlas.py new file mode 100644 index 00000000000..b631d79f359 --- /dev/null +++ b/agent/pet/generate/atlas.py @@ -0,0 +1,1183 @@ +"""Deterministic spritesheet assembly — generated row strips → Hermes atlas. + +Image-generation models are good at *drawing* a row of poses but bad at exact +grid geometry, so the model never owns the atlas layout: it produces one loose +horizontal strip per state, and these deterministic ops slice that strip into +clean, centered, transparent ``192x208`` cells and pack them into the sheet our +renderer reads. + +The atlas follows the **petdex/Codex standard**: 8 columns x 9 rows of +``192x208`` cells (``1536x1872``), with the row order + per-row frame counts +from OpenAI's ``hatch-pet`` skill. Our renderer (:mod:`agent.pet.render`) keys +frames as ``rows = states, cols = frames`` via +:data:`agent.pet.constants.CODEX_STATE_ROWS`, and a pet built here is a valid +``petdex submit`` spritesheet. Rows shorter than 8 columns leave the trailing +cells fully transparent. + +Note ``running`` is the *working* state (in-place processing), NOT locomotion — +``running-right`` / ``running-left`` are the actual directional walk cycles. + +The frame-segmentation, fit-to-cell, and transparency-residue logic is adapted +from OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0). +""" + +from __future__ import annotations + +import io +import logging +import math +from pathlib import Path + +from agent.pet.constants import FRAME_H, FRAME_W + +logger = logging.getLogger(__name__) + +CELL_WIDTH = FRAME_W +CELL_HEIGHT = FRAME_H + +# (state, row index, frame count). Order/row indices MUST match +# ``constants.CODEX_STATE_ROWS`` so the renderer crops the right row for each +# driven state, and the per-row frame counts mirror the petdex/Codex +# ``hatch-pet`` ``animation-rows`` spec. The renderer trims trailing blank +# columns, so rows shorter than ``COLUMNS`` (8) just leave the tail transparent. +ROW_SPECS: list[tuple[str, int, int]] = [ + ("idle", 0, 6), + ("running-right", 1, 8), + ("running-left", 2, 8), + ("waving", 3, 4), + ("jumping", 4, 5), + ("failed", 5, 8), + ("waiting", 6, 6), + ("running", 7, 6), + ("review", 8, 6), +] + +ROWS = len(ROW_SPECS) +COLUMNS = max(count for _, _, count in ROW_SPECS) +ATLAS_WIDTH = COLUMNS * CELL_WIDTH +ATLAS_HEIGHT = ROWS * CELL_HEIGHT + +FRAME_COUNTS: dict[str, int] = {state: count for state, _, count in ROW_SPECS} + +# Alpha at/below which a pixel is "background" for component detection. +_ALPHA_FLOOR = 16 +# Cell padding kept around a fitted sprite so poses never touch the edge. +_CELL_PAD = 10 +# Margin for the normalized pass — small, to fill the cell like real petdex pets +# (they sit ~5px from the edges); the width clamp, not the pad, prevents clipping. +_NORMALIZE_PAD = 14 +# Side-lobe cutoff for fitted frames. Adjacent-pose bleed usually appears as a +# small separated horizontal lobe beside the real subject; keep sizeable lobes so +# we don't punish a legitimate wide pose. +_SIDE_LOBE_RATIO = 0.18 + + +# ───────────────────────── background removal ───────────────────────── + + +def _color_distance(r: int, g: int, b: int, key: tuple[int, int, int]) -> float: + return math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) + + +def _has_transparency(image) -> bool: + """True if the strip already carries a real alpha background.""" + extrema = image.getchannel("A").getextrema() + # Min alpha 0 somewhere and a meaningful share of fully-transparent pixels. + if extrema[0] > _ALPHA_FLOOR: + return False + hist = image.getchannel("A").histogram() + transparent = sum(hist[: _ALPHA_FLOOR + 1]) + total = image.width * image.height + return transparent > total * 0.05 + + +def _dominant_corner_color(image) -> tuple[int, int, int]: + """Sample the four corners and return the most common opaque color.""" + from collections import Counter + + w, h = image.width, image.height + px = image.load() + counter: Counter = Counter() + for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)): + r, g, b, a = px[x, y] + if a > _ALPHA_FLOOR: + counter[(r, g, b)] += 1 + if not counter: + return (0, 255, 0) + return counter.most_common(1)[0][0] + + +def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48): + """An ``L`` mask, 255 where a pixel is within *tol* per-channel of *key*. + + Tight on purpose: it only marks near-pure backdrop so trapped chroma pockets + seed the flood, while chroma-*tinted* character pixels stay outside it. Built + with channel point-ops (fast C), no per-pixel Python. + """ + from PIL import ImageChops + + r, g, b, _a = image.split() + kr, kg, kb = key + return ImageChops.darker( + ImageChops.darker( + r.point(lambda v: 255 if abs(v - kr) <= tol else 0), + g.point(lambda v: 255 if abs(v - kg) <= tol else 0), + ), + b.point(lambda v: 255 if abs(v - kb) <= tol else 0), + ) + + +def _defringe(rgba): + """Shave the 1px antialiased edge ring left after keying. + + Chroma keying can't catch the antialiased band where the sprite meets the + backdrop — those pixels are a key/sprite blend, too far from the key to be + removed, so they ring the cutout in magenta/green. Erode the alpha by one + pixel (a 3x3 min filter) to drop that contaminated ring; the sprite's own + thick dark outline keeps the silhouette intact. Built on a C-level filter, no + per-pixel Python. + """ + from PIL import ImageFilter + + rgba.putalpha(rgba.getchannel("A").filter(ImageFilter.MinFilter(3))) + return rgba + + +def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 90.0): + """Return *image* (RGBA) with its flat background keyed out to transparent. + + If the strip already has a transparent background we leave it alone; else we + key out *chroma_key* (or the dominant corner color when not given) via a + **border flood-fill**: only background-coloured pixels *connected to an edge* + are removed. A global color match (the old approach) punched holes in the pet + wherever an interior highlight happened to match the backdrop — e.g. a pug's + light belly against a near-white background — which then showed through as the + window behind. Flood-fill keeps those interior pixels because they aren't + reachable from the border without crossing the (non-background) pet. + """ + from collections import deque + + from PIL import Image, ImageChops + + rgba = image.convert("RGBA") + if _has_transparency(rgba): + return _repair_internal_alpha_holes(rgba) + + key = chroma_key or _dominant_corner_color(rgba) + w, h = rgba.width, rgba.height + px = rgba.load() + + def _is_bg(x: int, y: int) -> bool: + r, g, b, a = px[x, y] + return a > _ALPHA_FLOOR and _color_distance(r, g, b, key) <= threshold + + # Fast path for strongly-saturated chroma keys (our normal sprite prompts use + # hot magenta): remove all near-key opaque pixels with C-level channel ops. + # This clears both border-connected backdrop and enclosed triangular pockets + # between connected limbs/capes, without a Python flood over ~1.5M pixels. + if max(key) - min(key) >= 120: + near = _near_key_mask(rgba, key) # L mask, 255 where near key + opaque = rgba.getchannel("A").point(lambda a: 255 if a > _ALPHA_FLOOR else 0) + remove_mask = ImageChops.darker(near, opaque) + keyed = Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, remove_mask) + return _defringe(keyed) + + visited = bytearray(w * h) + # Mark removals in a flat mask and apply them in one C composite at the end — + # writing `px[x, y] = (0,0,0,0)` per pixel was ~3M PixelAccess calls (84% of + # the whole pipeline) and pegged a core in pure Python, stalling the gateway. + remove = bytearray(w * h) + queue: deque[tuple[int, int]] = deque() + + # Seed from every border pixel that looks like background. + for x in range(w): + for y in (0, h - 1): + if _is_bg(x, y) and not visited[y * w + x]: + visited[y * w + x] = 1 + queue.append((x, y)) + for y in range(h): + for x in (0, w - 1): + if _is_bg(x, y) and not visited[y * w + x]: + visited[y * w + x] = 1 + queue.append((x, y)) + + # Trapped pockets: background enclosed by the character (the magenta between + # an arm and the body) isn't border-reachable, so also seed the flood from + # interior near-key pixels. Gated to a *saturated* key (our magenta backdrop) + # so we never seed from a character sharing a desaturated near-white/gray key + # — that's the hole-punching the border-only flood exists to avoid. + if max(key) - min(key) >= 120: + for i, near in enumerate(_near_key_mask(rgba, key).getdata()): + if near and not visited[i]: + visited[i] = 1 + queue.append((i % w, i // w)) + + while queue: + x, y = queue.popleft() + remove[y * w + x] = 1 + for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + if 0 <= nx < w and 0 <= ny < h: + idx = ny * w + nx + if not visited[idx]: + visited[idx] = 1 + if _is_bg(nx, ny): + queue.append((nx, ny)) + + # One C-level composite instead of millions of per-pixel writes: paint the + # flooded pixels to (0,0,0,0) wherever the mask is set. + mask = Image.frombytes("L", (w, h), bytes(remove)).point(lambda v: 255 if v else 0) + return _defringe(Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, mask)) + + +def _repair_internal_alpha_holes(image): + """Fill transparent islands fully enclosed by opaque sprite pixels. + + Some providers return "transparent" PNGs with swiss-cheese alpha inside the + character. Border flood-fill cannot see those because there is no opaque + backdrop to key, so repair the alpha mask itself: transparent components that + touch an image edge remain background; transparent components enclosed by + the sprite are filled with the average color of their opaque neighbours. + """ + from collections import deque + + rgba = image.convert("RGBA") + w, h = rgba.size + px = rgba.load() + visited = bytearray(w * h) + + def _is_transparent(x: int, y: int) -> bool: + return px[x, y][3] <= _ALPHA_FLOOR + + def _mark_border_component(sx: int, sy: int) -> None: + queue: deque[tuple[int, int]] = deque([(sx, sy)]) + visited[sy * w + sx] = 1 + while queue: + x, y = queue.popleft() + for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + if 0 <= nx < w and 0 <= ny < h: + idx = ny * w + nx + if not visited[idx] and _is_transparent(nx, ny): + visited[idx] = 1 + queue.append((nx, ny)) + + # First mark true background: all transparent pixels reachable from the edge. + for x in range(w): + for y in (0, h - 1): + if _is_transparent(x, y) and not visited[y * w + x]: + _mark_border_component(x, y) + for y in range(h): + for x in (0, w - 1): + if _is_transparent(x, y) and not visited[y * w + x]: + _mark_border_component(x, y) + + def _collect_hole(sx: int, sy: int) -> list[tuple[int, int]]: + queue: deque[tuple[int, int]] = deque([(sx, sy)]) + visited[sy * w + sx] = 1 + pixels: list[tuple[int, int]] = [] + while queue: + x, y = queue.popleft() + pixels.append((x, y)) + for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + if 0 <= nx < w and 0 <= ny < h: + idx = ny * w + nx + if not visited[idx] and _is_transparent(nx, ny): + visited[idx] = 1 + queue.append((nx, ny)) + return pixels + + def _fill_color(hole: list[tuple[int, int]]) -> tuple[int, int, int, int]: + samples: list[tuple[int, int, int]] = [] + seen = set(hole) + for x, y in hole: + for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen: + r, g, b, a = px[nx, ny] + if a > _ALPHA_FLOOR: + samples.append((r, g, b)) + if not samples: + return (0, 0, 0, 255) + return ( + round(sum(c[0] for c in samples) / len(samples)), + round(sum(c[1] for c in samples) / len(samples)), + round(sum(c[2] for c in samples) / len(samples)), + 255, + ) + + for start, _ in enumerate(visited): + if visited[start]: + continue + x = start % w + y = start // w + if not _is_transparent(x, y): + continue + hole = _collect_hole(x, y) + color = _fill_color(hole) + for hx, hy in hole: + px[hx, hy] = color + return rgba + + +# ───────────────────────── frame extraction ───────────────────────── + + +def _fit_to_cell(image): + """Crop to content, scale to fit a padded cell, and center on transparent.""" + from PIL import Image + + target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) + image = _drop_side_bleed(image) + bbox = image.getbbox() + if bbox is None: + return target + + sprite = image.crop(bbox) + max_w = CELL_WIDTH - _CELL_PAD + max_h = CELL_HEIGHT - _CELL_PAD + scale = min(max_w / sprite.width, max_h / sprite.height, 1.0) + if scale != 1.0: + # NEAREST, not LANCZOS: the generated "pixel art" has hard edges, and any + # interpolating resample anti-aliases them into a blurry, washed-out + # sprite once the renderer upscales the cell. Crisp blocky downscale reads + # as real pixel art. + sprite = sprite.resize( + (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))), + Image.Resampling.NEAREST, + ) + left = (CELL_WIDTH - sprite.width) // 2 + top = (CELL_HEIGHT - sprite.height) // 2 + target.alpha_composite(sprite, (left, top)) + return target + + +def _drop_side_bleed(image): + """Remove tiny separated left/right lobes before fitting a frame. + + Frogger showed the failure mode: a good centered pose plus a thin vertical + sliver from the neighbouring pose. By the time it reaches a cell, that sliver + may be close enough to the subject that component extraction already grouped + it. A horizontal alpha projection still reveals it as a small side lobe with + a low mass compared to the main silhouette. Drop only those low-mass lobes; + keep large lobes so wide poses and real limbs survive. + """ + from PIL import Image + + rgba = image.convert("RGBA") + w, h = rgba.size + profile = _column_profile(rgba) # mean alpha per column (fast C resize) + + runs = _content_runs(profile) + if len(runs) < 2: + return rgba + masses = [sum(profile[l:r]) for l, r in runs] + keep_mass = max(masses) * _SIDE_LOBE_RATIO + keep = [run for run, m in zip(runs, masses) if m >= keep_mass] + if len(keep) == len(runs): + return rgba + + # Zero every column band that isn't a kept segment (box paste, not per-pixel). + rgba = rgba.copy() + cut, prev = Image.new("RGBA", (w, h), (0, 0, 0, 0)), 0 + for left, right in keep: + if left > prev: + rgba.paste(cut.crop((prev, 0, left, h)), (prev, 0)) + prev = right + if prev < w: + rgba.paste(cut.crop((prev, 0, w, h)), (prev, 0)) + return rgba + + +def _erase_long_axis_lines(image): + """Remove thin slot-spanning guide/floor/divider lines. + + Gemini will sometimes satisfy "baseline" / "cell" language by drawing + literal horizontal floors or vertical panel dividers. They survive chroma + keying and connect otherwise clean poses. Drop only *thin* rows/columns that + span nearly the whole slot; thick sprite body rows are left alone. + """ + from PIL import Image + + rgba = image.convert("RGBA").copy() + w, h = rgba.size + alpha = rgba.getchannel("A") + + def _thin_groups(indices: list[int]) -> list[tuple[int, int]]: + groups: list[tuple[int, int]] = [] + start: int | None = None + prev: int | None = None + for idx in indices: + if start is None: + start = prev = idx + continue + if prev is not None and idx == prev + 1: + prev = idx + continue + if start is not None and prev is not None and prev - start + 1 <= 4: + groups.append((start, prev + 1)) + start = prev = idx + if start is not None and prev is not None and prev - start + 1 <= 4: + groups.append((start, prev + 1)) + return groups + + wide_rows = [ + y + for y in range(h) + if sum(1 for x in range(w) if alpha.getpixel((x, y)) > _ALPHA_FLOOR) >= w * 0.85 + ] + tall_cols = [ + x + for x in range(w) + if sum(1 for y in range(h) if alpha.getpixel((x, y)) > _ALPHA_FLOOR) >= h * 0.85 + ] + + clear = Image.new("RGBA", rgba.size, (0, 0, 0, 0)) + for top, bottom in _thin_groups(wide_rows): + rgba.paste(clear.crop((0, top, w, bottom)), (0, top)) + for left, right in _thin_groups(tall_cols): + rgba.paste(clear.crop((left, 0, right, h)), (left, 0)) + return rgba + + +def _component_boxes(image) -> list[tuple[tuple[int, int, int, int], int]]: + """Connected opaque components as ``[(bbox, mass)]``. + + A full ML segmenter would be overkill here: after chroma keying, "the pet" is + the dominant connected alpha component inside each known slot. Tiny detached + sparkles, tears, UI dots, and neighbour slivers are separate components. + """ + from collections import deque + + rgba = image.convert("RGBA") + bbox = rgba.getbbox() + if bbox is None: + return [] + l0, t0, r0, b0 = bbox + w, h = r0 - l0, b0 - t0 + alpha = rgba.getchannel("A").load() + visited = bytearray(w * h) + out: list[tuple[tuple[int, int, int, int], int]] = [] + + for start in range(w * h): + if visited[start]: + continue + sx, sy = start % w, start // w + ax, ay = l0 + sx, t0 + sy + visited[start] = 1 + if alpha[ax, ay] <= _ALPHA_FLOOR: + continue + + queue: deque[tuple[int, int]] = deque([(sx, sy)]) + left = right = sx + top = bottom = sy + mass = 0 + while queue: + x, y = queue.popleft() + mass += 1 + left, right = min(left, x), max(right, x) + top, bottom = min(top, y), max(bottom, y) + for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + if 0 <= nx < w and 0 <= ny < h: + idx = ny * w + nx + if not visited[idx]: + visited[idx] = 1 + if alpha[l0 + nx, t0 + ny] > _ALPHA_FLOOR: + queue.append((nx, ny)) + out.append(((l0 + left, t0 + top, l0 + right + 1, t0 + bottom + 1), mass)) + return out + + +def _isolate_slot_subject(image): + """Keep the slot's real subject; drop detached effects/noise.""" + from PIL import Image + + rgba = _erase_long_axis_lines(image) + comps = _component_boxes(rgba) + if not comps: + return rgba + + main_box, main_mass = max(comps, key=lambda item: item[1]) + ml, mt, mr, mb = main_box + mw = max(1, mr - ml) + keep: list[tuple[int, int, int, int]] = [] + for box, mass in comps: + if box == main_box: + keep.append(box) + continue + left, _top, right, _bottom = box + overlap = max(0, min(right, mr) - max(left, ml)) + center_x = (left + right) / 2 + near_main = (ml - mw * 0.25) <= center_x <= (mr + mw * 0.25) + # Keep meaningful attached-looking accessories such as halos; drop + # sparkles/tears/noise that don't overlap the body column. + if mass >= max(24, main_mass * 0.035) and (overlap >= mw * 0.3 or near_main): + keep.append(box) + + out = Image.new("RGBA", rgba.size, (0, 0, 0, 0)) + for box in keep: + out.alpha_composite(rgba.crop(box), (box[0], box[1])) + return out + + +def _has_slot_padding(image) -> bool: + """True when content has empty room on all four slot edges.""" + bbox = image.getbbox() + if bbox is None: + return False + w, h = image.size + left, top, right, bottom = bbox + min_x = max(4, min(12, round(w * 0.025))) + min_y = max(4, min(16, round(h * 0.02))) + return left >= min_x and top >= min_y and w - right >= min_x and h - bottom >= min_y + + +def _slot_bounds(width: int, frame_count: int) -> list[tuple[int, int]]: + return [ + (round(i * width / frame_count), round((i + 1) * width / frame_count)) + for i in range(frame_count) + ] + + +def _group_component_rows(boxes: list[tuple[int, int, int, int]]) -> list[list[tuple[int, int, int, int]]]: + """Group component boxes into visual rows, then sort left→right.""" + if not boxes: + return [] + heights = sorted(max(1, b[3] - b[1]) for b in boxes) + row_tol = max(12, heights[len(heights) // 2] * 0.55) + rows: list[list[tuple[int, int, int, int]]] = [] + centers: list[float] = [] + for box in sorted(boxes, key=lambda b: (b[1] + b[3]) / 2): + cy = (box[1] + box[3]) / 2 + for i, center in enumerate(centers): + if abs(cy - center) <= row_tol: + rows[i].append(box) + centers[i] = sum((b[1] + b[3]) / 2 for b in rows[i]) / len(rows[i]) + break + else: + rows.append([box]) + centers.append(cy) + ordered = [row for _center, row in sorted(zip(centers, rows, strict=False), key=lambda item: item[0])] + for row in ordered: + row.sort(key=lambda b: (b[0] + b[2]) / 2) + return ordered + + +def _merge_related_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]: + """Merge disconnected parts that clearly belong to one subject. + + Capes, tails, horns, and held props sometimes key as separate components. + Merge components on the same visual row when their vertical spans overlap and + the horizontal gap is tiny compared with the component size. Do not bridge the + much larger gaps between separate poses. + """ + boxes = list(boxes) + changed = True + while changed: + changed = False + merged: list[tuple[int, int, int, int]] = [] + used = [False] * len(boxes) + for i, a in enumerate(boxes): + if used[i]: + continue + al, at, ar, ab = a + used[i] = True + for j in range(i + 1, len(boxes)): + if used[j]: + continue + bl, bt, br, bb = boxes[j] + v_overlap = max(0, min(ab, bb) - max(at, bt)) + min_h = max(1, min(ab - at, bb - bt)) + gap = max(0, max(al, bl) - min(ar, br)) + min_w = max(1, min(ar - al, br - bl)) + if v_overlap >= min_h * 0.45 and gap <= max(14, min_w * 0.22): + al, at, ar, ab = min(al, bl), min(at, bt), max(ar, br), max(ab, bb) + used[j] = True + changed = True + merged.append((al, at, ar, ab)) + boxes = merged + return boxes + + +def _component_crops(strip, frame_count: int, *, require_padding: bool = False) -> list | None: + """Extract frame subjects as connected non-background objects. + + This is the robust path for models that ignore "one horizontal row" and emit a + 2D sprite grid. We count real opaque subject components, discard tiny + detached effects, sort in reading order, and return exactly *frame_count* + frames. Slot slicing is only a fallback when object detection can't satisfy + the contract. + """ + from PIL import Image + + def attempt(source) -> list | None: + comps = _component_boxes(source) + if not comps: + return None + + max_mass = max(m for _box, m in comps) + subjects = _merge_related_boxes([box for box, mass in comps if mass >= max(64, max_mass * 0.12)]) + if len(subjects) < frame_count: + return None + + rows = _group_component_rows(subjects) + ordered = [box for row in rows for box in row][:frame_count] + if len(ordered) < frame_count: + return None + + if require_padding: + min_x = max(4, min(12, round(source.width * 0.01))) + min_y = max(4, min(16, round(source.height * 0.015))) + for left, top, right, bottom in ordered: + if left < min_x or top < min_y or source.width - right < min_x or source.height - bottom < min_y: + return None + + multirow = len(rows) > 1 + frames = [] + for left, top, right, bottom in ordered: + pad_x = max(8, round((right - left) * 0.08)) + pad_y = max(8, round((bottom - top) * 0.08)) + if multirow: + crop_box = ( + max(0, left - pad_x), + max(0, top - pad_y), + min(source.width, right + pad_x), + min(source.height, bottom + pad_y), + ) + elif frame_count == 1: + crop_box = (0, 0, source.width, source.height) + else: + # Preserve vertical motion for true one-row strips (jumping, + # bobbing) while still narrowing X around the object. + crop_box = (max(0, left - pad_x), 0, min(source.width, right + pad_x), source.height) + frame = Image.new("RGBA", (crop_box[2] - crop_box[0], crop_box[3] - crop_box[1]), (0, 0, 0, 0)) + rel = (left - crop_box[0], top - crop_box[1], right - crop_box[0], bottom - crop_box[1]) + frame.alpha_composite(source.crop((left, top, right, bottom)), (rel[0], rel[1])) + # The global component pass already chose the subject box. Do not run + # another component filter here: capes/tails can be legitimate + # disconnected lobes inside the chosen subject box. + frames.append(frame) + return frames + + return attempt(strip) or attempt(_erase_long_axis_lines(strip)) + + +def _sever_expected_gutters(strip, frame_count: int): + """Cut thin vertical gutters at expected frame boundaries before labeling. + + Generated rows often have a shared shadow, glow, motion smear, or 1px bridge + that connects neighbouring poses. Component detection then sees one giant + blob and either fails or falls back to slot slicing. We know the requested + frame count, so cut a very narrow transparent band at each expected boundary + before connected-component labeling. If a pose truly overlaps the boundary, + losing a few pixels is better than exporting merged frames. + """ + if frame_count <= 1: + return strip + + out = strip.copy() + px = out.load() + slot = out.width / frame_count + half = max(3, min(18, round(slot * 0.06))) + for i in range(1, frame_count): + x = round(i * slot) + left = max(0, x - half) + right = min(out.width, x + half + 1) + for gx in range(left, right): + for gy in range(out.height): + r, g, b, _a = px[gx, gy] + px[gx, gy] = (r, g, b, 0) + return out + + +def _slot_crops(strip, frame_count: int, *, require_padding: bool = False) -> list | None: + """Slice *strip* into *frame_count* uniform columns (one coordinate space). + + Equal-width columns keep every frame in a single shared coordinate frame, so + a later union-crop + shared placement (:func:`normalize_cells`) preserves the + row's real motion without the per-frame re-centering that makes a pet visibly + slide. Each slot is cleaned independently so detached effects, floors, + dividers, and neighbour slivers do not become "frames". + """ + h = strip.height + frames = [] + for left, right in _slot_bounds(strip.width, frame_count): + slot = _drop_side_bleed(_isolate_slot_subject(strip.crop((left, 0, right, h)))) + if require_padding and not _has_slot_padding(slot): + return None + frames.append(slot) + return frames + + +def _content_runs(profile: list[int], *, threshold: int = 2) -> list[tuple[int, int]]: + """Contiguous column spans whose alpha mass exceeds *threshold*. + + A column-projection of the alpha mask: empty (background) columns separate + one pose from the next, so the runs ARE the candidate frames. + """ + runs: list[tuple[int, int]] = [] + start: int | None = None + for x, v in enumerate(list(profile) + [0]): + if v > threshold: + if start is None: + start = x + elif start is not None: + runs.append((start, x)) + start = None + return runs + + +def _frame_x_ranges(strip, frame_count: int) -> list[tuple[int, int]] | None: + """Per-frame ``(left, right)`` column ranges from the row's empty gutters. + + The standard sprite-sheet slice — once poses are separated by real gaps + (which generation now enforces), splitting is just "find the empty columns": + + * spans == frames → one span per frame. + * spans > frames → merge across the smallest gaps. A detached halo/ear sits + a tiny gap from its body, while the inter-pose gutter is the big gap that + survives — so over-segmentation (and any over-eager gutter sever) repairs + itself by collapsing only the small internal gaps. + * spans < frames → poses are touching; not separable by gutters (the caller + raises for ``components`` or falls back to even slots for ``auto``). + + Ranges span content only; the caller crops full cell height, so tall ears / + halos are never cut. + """ + profile = _column_profile(strip) + runs = _content_runs(profile) + if not runs: + return None + + # Drop trivial specks so stray noise never counts as a pose. + masses = [sum(profile[l:r]) for l, r in runs] + floor = max(masses) * 0.02 + runs = [run for run, m in zip(runs, masses) if m >= floor] + if len(runs) < frame_count: + return None + + groups = [[l, r] for l, r in runs] + while len(groups) > frame_count: + gi = min(range(len(groups) - 1), key=lambda i: groups[i + 1][0] - groups[i][1]) + groups[gi][1] = groups[gi + 1][1] + del groups[gi + 1] + return [(l, r) for l, r in groups] + + +def _significant_subject_boxes(image) -> list[tuple[int, int, int, int]]: + comps = _component_boxes(image) + if not comps: + return [] + max_mass = max(mass for _box, mass in comps) + return _merge_related_boxes([box for box, mass in comps if mass >= max(32, max_mass * 0.12)]) + + +def _validate_extracted_frames(frames: list, frame_count: int) -> None: + """Reject rows where one "frame" is really multiple poses. + + A bad provider roll can collapse a strip into tiny repeated poses. If we let + that through, normalization sees a huge motion envelope and shrinks the + entire pet to postage-stamp size. Catch the row here so hatch can regenerate + it instead of saving a technically non-empty but visually broken atlas. + """ + if len(frames) != frame_count: + raise ValueError(f"expected {frame_count} frames, got {len(frames)}") + + boxes = [] + for i, frame in enumerate(frames): + bbox = frame.getbbox() + if bbox is None: + raise ValueError(f"frame {i} is empty") + subjects = _significant_subject_boxes(frame) + if len(subjects) >= 3: + raise ValueError(f"frame {i} contains multiple separated subjects") + boxes.append(bbox) + + if frame_count <= 1: + return + + widths = sorted(b[2] - b[0] for b in boxes) + heights = sorted(b[3] - b[1] for b in boxes) + med_w = max(1, widths[len(widths) // 2]) + med_h = max(1, heights[len(heights) // 2]) + for i, (left, top, right, bottom) in enumerate(boxes): + width = right - left + height = bottom - top + # A legitimate wing/arm can be wider than the median pose. A frame that is + # several times wider while not proportionally taller is usually multiple + # mini-poses packed into one accepted frame. + if width > max(med_w * 3.0, med_w + 96) and height <= med_h * 1.6: + raise ValueError(f"frame {i} is a multi-pose width outlier") + + +def extract_strip_frames( + strip, + frame_count: int, + *, + chroma_key: tuple[int, int, int] | None = None, + method: str = "auto", + fit: bool = True, +) -> list: + """Turn one generated row strip into *frame_count* frames. + + The background is keyed out, then strict extraction treats the requested + frame count as the source of truth: slice known equal slots, isolate the real + subject in each slot, and require empty padding on X and Y. Empty chroma + gutters are only a lenient salvage fallback. + + Each frame is cropped at full cell height so tall ears / halos are never + clipped; detached effects and neighbour slivers are dropped per slot. When a + pose does not have required space around it, ``components`` raises and + ``auto`` falls back to best-effort slicing. + + *fit* (default) fits+centers each frame into a 192x208 cell — the standalone + contract for callers that don't normalize. Hatching passes ``fit=False`` to + keep raw, coordinate-aligned columns for :func:`normalize_cells`, which lays + one shared scale + baseline across the whole pet (no slide, no size pulse). + """ + from PIL import Image + + if isinstance(strip, (str, Path)): + with Image.open(strip) as opened: + strip = opened.convert("RGBA") + else: + strip = strip.convert("RGBA") + + strip = remove_background(strip, chroma_key=chroma_key) + + # Strict path: count actual non-background subjects first. This handles both + # the intended one-row strip and model-cheated 2D grids without ever stacking + # two visual rows into one frame. + frames = _component_crops(strip, frame_count, require_padding=True) + if frames is None: + frames = _slot_crops(strip, frame_count, require_padding=True) + if frames is None: + if method == "components": + raise ValueError(f"could not segment {frame_count} padded sprites from strip") + + # Lenient salvage for the final attempt: prefer real gutters when they + # exist, then sever expected boundaries, then fall back to raw slots. Still + # try object extraction first, just without edge-padding enforcement, so + # cached/borderline model rolls can be inspected without stacking a 2D grid. + frames = _component_crops(strip, frame_count, require_padding=False) + if frames is None: + source = strip + ranges = _frame_x_ranges(source, frame_count) + if ranges is None: + source = _sever_expected_gutters(strip, frame_count) + ranges = _frame_x_ranges(source, frame_count) + + if ranges is None: + frames = _slot_crops(source, frame_count, require_padding=False) or [] + else: + h = source.height + pad = max(2, min(16, round((source.width / max(1, frame_count)) * 0.04))) + frames = [ + _drop_side_bleed(_isolate_slot_subject(source.crop((max(0, left - pad), 0, min(source.width, right + pad), h)))) + for left, right in ranges + ] + _validate_extracted_frames(frames, frame_count) + return [_fit_to_cell(f) for f in frames] if fit else frames + + +def _column_profile(image) -> list[int]: + """Per-column alpha mass — collapse the frame to a 1px-tall strip (fast in C).""" + from PIL import Image + + return list(image.getchannel("A").resize((image.width, 1), Image.BILINEAR).getdata()) + + +def _best_shift(ref: list[int], prof: list[int], window: int) -> int: + """Integer dx that best aligns *prof* onto *ref* by cross-correlation. + + This is 1-D phase correlation: the body is the dominant mass in the column + profile, so the peak overlap locks onto the body and a flipping arm/cape (a + small secondary bump) doesn't move the match. Proven on the jitter case to + cut body drift from ~9px to ~1px where a centroid/bbox anchor cannot. + """ + n = len(ref) + best_score: float | None = None + best = 0 + for d in range(-window, window + 1): + score = 0 + for x in range(max(0, d), min(n, n + d)): + score += ref[x] * prof[x - d] + if best_score is None or score > best_score: + best_score = score + best = d + return best + + +def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_PAD) -> dict[str, list]: + """Register every frame into a 192x208 cell — the deterministic anti-jitter math. + + A per-frame "crop→scale→center" pipeline jitters because a moving limb/cape + shifts the bbox (or even the centroid) and a per-frame scale pulses the size. + The rigorous fix, matching image-registration practice (phase correlation) + and AI-sprite pipelines (perfectpixel-studio / sprite-gen): + + 1. **Cross-correlate** each frame's column profile against the per-state + *median* profile to find the integer shift that locks the **body** in + place — robust to limbs/cape because the body dominates the profile. + 2. **Union-crop** through one shared state window, then scale every state by a + single global factor keyed to its median pose height, so the character is + the same on-screen size in every row while a jump's lift still fits. + """ + from PIL import Image + + blank = lambda: Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) + med = lambda vs: sorted(vs)[len(vs) // 2] # robust center; ignores a limb/cape outlier + + out: dict[str, list] = {} + prepared: dict[str, tuple[list, tuple[int, int, int, int], tuple[int, int]]] = {} + # Fill the cell — real petdex pets sit ~pad from the edges; the K cap below + # keeps a tall pose (a jump's lift) from clipping. + target_w = CELL_WIDTH - pad + target_h = CELL_HEIGHT - pad + + for state, frames in frames_by_state.items(): + rgba = [f.convert("RGBA") for f in frames] + if not any(f.getbbox() for f in rgba): + out[state] = [blank() for _ in frames] + continue + + # Pad every frame to a common canvas so column profiles are comparable. + w0 = max(f.width for f in rgba) + h0 = max(f.height for f in rgba) + canvas = [] + for f in rgba: + if f.size != (w0, h0): + c = Image.new("RGBA", (w0, h0), (0, 0, 0, 0)) + c.alpha_composite(f, (0, 0)) + f = c + canvas.append(f) + + # Register horizontally: shift each frame to lock the body (xcorr). + profiles = [_column_profile(f) for f in canvas] + ref = [sorted(p[x] for p in profiles)[len(profiles) // 2] for x in range(w0)] + window = max(8, w0 // 5) + margin = window + aligned = [] + for f, prof in zip(canvas, profiles): + shifted = Image.new("RGBA", (w0 + 2 * margin, h0), (0, 0, 0, 0)) + shifted.alpha_composite(f, (margin + _best_shift(ref, prof, window), 0)) + aligned.append(shifted) + + # Shared window over the registered set; scale is resolved against a + # common apparent-character target below. + boxes = [b for b in (a.getbbox() for a in aligned) if b] + left = min(b[0] for b in boxes) + top = min(b[1] for b in boxes) + right = max(b[2] for b in boxes) + bottom = max(b[3] for b in boxes) + prepared[state] = ( + aligned, + (left, top, right, bottom), + (med([b[2] - b[0] for b in boxes]), med([b[3] - b[1] for b in boxes])), + ) + + if not prepared: + return out + + # Uniform apparent size: scale each state by K / pose_h, so a row the model + # drew small renders as big as one it drew large. K is the one global cap that + # keeps the tallest/widest motion envelope (a jump's lift) inside the cell — + # for a still row union ≈ pose so its term ≈ target_h (full fill). + K = target_h + for (_aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.values(): + uw, uh = right - left, bottom - top + K = min(K, target_h * pose_h / max(1, uh), target_w * pose_h / max(1, uw)) + + for state, (aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.items(): + uw, uh = right - left, bottom - top + scale = K / max(1, pose_h) + sw, sh = max(1, round(uw * scale)), max(1, round(uh * scale)) + px, py = round((CELL_WIDTH - sw) / 2), round((CELL_HEIGHT - pad // 2) - sh) + + cells = [] + for a in aligned: + crop = a.crop((left, top, right, bottom)) + if crop.size != (sw, sh): + # NEAREST keeps the pixel-art edges crisp; LANCZOS blurred them. + crop = crop.resize((sw, sh), Image.Resampling.NEAREST) + cell = blank() + cell.alpha_composite(crop, (px, py)) + cells.append(cell) + out[state] = cells + return out + + +# ───────────────────────── atlas composition ───────────────────────── + + +def single_frame(image, *, fit: bool = True): + """One frame from a standalone image (e.g. the base look). + + Used as an idle fallback so a pet always renders even if the idle row + generation failed. *fit* yields a finished 192x208 cell; ``fit=False`` yields + the raw keyed sprite for :func:`normalize_cells` to place with the rest. + """ + from PIL import Image + + if isinstance(image, (str, Path)): + with Image.open(image) as opened: + image = opened.convert("RGBA") + keyed = remove_background(image) + return _fit_to_cell(keyed) if fit else _drop_side_bleed(keyed) + + +def _clear_transparent_rgb(image): + """Zero the RGB of fully-transparent pixels (no colored-halo residue).""" + from PIL import Image + + rgba = image.convert("RGBA") + data = bytearray(rgba.tobytes()) + for i in range(0, len(data), 4): + if data[i + 3] == 0: + data[i] = data[i + 1] = data[i + 2] = 0 + return Image.frombytes("RGBA", rgba.size, bytes(data)) + + +def mirror_frames(frames: list) -> list: + """Horizontally flip each frame *in place* (RGBA-safe). + + Used to derive ``running-left`` from an approved ``running-right`` row. The + flip is per-frame so the leftward loop preserves the rightward loop's frame + order and timing — this is NOT a whole-strip reverse (which would play the + animation backwards), matching the petdex/Codex mirror rule. + """ + from PIL import Image + + flip = getattr(Image, "Transpose", Image).FLIP_LEFT_RIGHT + return [frame.convert("RGBA").transpose(flip) for frame in frames] + + +def compose_atlas(frames_by_state: dict[str, list]): + """Pack per-state frame lists into the Hermes atlas (RGBA, residue-cleared). + + Missing/short states leave their trailing cells transparent; extra frames + beyond a state's spec are dropped. + """ + from PIL import Image + + atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0)) + for state, row, count in ROW_SPECS: + frames = frames_by_state.get(state) or [] + for col, frame in enumerate(frames[:count]): + cell = frame.convert("RGBA") + if cell.size != (CELL_WIDTH, CELL_HEIGHT): + cell = _fit_to_cell(cell) + atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT)) + return _clear_transparent_rgb(atlas) + + +def atlas_to_webp_bytes(atlas) -> bytes: + """Encode an atlas image to lossless WebP bytes (the on-disk pet format).""" + buf = io.BytesIO() + atlas.save(buf, format="WEBP", lossless=True, quality=100, method=6, exact=True) + return buf.getvalue() + + +def validate_atlas(atlas) -> dict: + """Check geometry, per-cell occupancy, and transparency invariants. + + Returns ``{ok, width, height, errors, warnings, filled_states}``. Errors are + blockers (wrong size, empty used cell, opaque/dirty transparency); warnings + are soft (a whole state row blank — generation likely dropped a row). + """ + from PIL import Image + + if isinstance(atlas, (str, Path)): + with Image.open(atlas) as opened: + atlas = opened.convert("RGBA") + else: + atlas = atlas.convert("RGBA") + + errors: list[str] = [] + warnings: list[str] = [] + + if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT): + errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}") + return {"ok": False, "width": atlas.width, "height": atlas.height, "errors": errors, "warnings": warnings, "filled_states": []} + + filled_states: list[str] = [] + cell_boxes_by_state: dict[str, list[tuple[int, int, int, int]]] = {} + for state, row, count in ROW_SPECS: + row_pixels = 0 + boxes: list[tuple[int, int, int, int]] = [] + for col in range(count): + left = col * CELL_WIDTH + top = row * CELL_HEIGHT + cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT)) + nonblank = sum(cell.getchannel("A").histogram()[1:]) + row_pixels += nonblank + bbox = cell.getbbox() + if bbox is not None: + boxes.append(bbox) + if row_pixels > 0: + filled_states.append(state) + cell_boxes_by_state[state] = boxes + else: + warnings.append(f"state '{state}' has no frames") + + if not filled_states: + errors.append("atlas is empty — no state produced any frames") + + # A visually valid pet must occupy the cell. A single bad row can otherwise + # poison global normalization and shrink every state to a tiny postage stamp + # while still passing the old "non-empty cells" check. + all_widths = sorted( + right - left + for boxes in cell_boxes_by_state.values() + for left, _top, right, _bottom in boxes + ) + all_heights = sorted( + bottom - top + for boxes in cell_boxes_by_state.values() + for _left, top, _right, bottom in boxes + ) + global_med_w = 0 + global_med_h = 0 + if all_widths and all_heights: + global_med_w = all_widths[len(all_widths) // 2] + median_h = all_heights[len(all_heights) // 2] + global_med_h = median_h + min_h = max(56, round(CELL_HEIGHT * 0.28)) + if median_h < min_h: + errors.append(f"atlas sprites are too small after normalization (median frame height {median_h}px)") + + for state, boxes in cell_boxes_by_state.items(): + if len(boxes) <= 1: + continue + widths = sorted(right - left for left, _top, right, _bottom in boxes) + heights = sorted(bottom - top for _left, top, _right, bottom in boxes) + med_w = max(1, widths[len(widths) // 2]) + med_h = max(1, heights[len(heights) // 2]) + max_w = widths[-1] + max_h = heights[-1] + if max_w > max(med_w * 3.0, med_w + 96) and max_h <= med_h * 1.6: + errors.append(f"state '{state}' contains a multi-pose frame outlier") + # Per-state collapse guard: one malformed row (tiny slivers / chopped + # fragments) should not pass because other rows are healthy. + if global_med_w and global_med_h: + min_state_w = max(32, round(global_med_w * 0.42)) + min_state_h = max(40, round(global_med_h * 0.50)) + if med_w < min_state_w or med_h < min_state_h: + errors.append( + f"state '{state}' appears collapsed (median {med_w}x{med_h}px, global median {global_med_w}x{global_med_h}px)" + ) + + # Transparent pixels must carry zero RGB (no halo residue). + data = atlas.tobytes() + residue = 0 + for i in range(0, len(data), 4): + if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]): + residue += 1 + if residue: + errors.append(f"{residue} transparent pixels retain RGB residue") + + return { + "ok": not errors, + "width": atlas.width, + "height": atlas.height, + "errors": errors, + "warnings": warnings, + "filled_states": filled_states, + } diff --git a/agent/pet/generate/imagegen.py b/agent/pet/generate/imagegen.py new file mode 100644 index 00000000000..4f5000fd703 --- /dev/null +++ b/agent/pet/generate/imagegen.py @@ -0,0 +1,251 @@ +"""Thin image-generation layer for pet sprites. + +Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the +two things sprite generation needs that the agent-facing ``image_generate`` tool +doesn't expose: **N variants** (loop) and **reference-image grounding** (so each +animation row stays the same character as the chosen base). + +Reference grounding only works on providers that support it — currently OpenAI +``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of +those and surface a clear, actionable error otherwise rather than silently +producing an ungrounded, drifting pet. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Providers that can ground generation on a reference image, in preference order +# (Nous Portal → OpenAI → OpenRouter → …). OpenRouter/Nous run a quality-first +# model chain and may fall back depending on account access and endpoint behavior, +# so fidelity can vary by configured backend + model availability. +_REF_CAPABLE = ("nous", "openai", "openai-codex", "openrouter", "krea") + +# Friendly display label per reference-capable provider, surfaced in the desktop +# pet-gen picker. +_PROVIDER_LABELS: dict[str, str] = { + "nous": "Nous Portal", + "openrouter": "OpenRouter", + "openai": "OpenAI", + "openai-codex": "OpenAI (Codex)", + "krea": "Krea", +} + + +def _forced_provider_from_env() -> str | None: + """Optional QA override to force a pet-gen backend. + + `HERMES_PET_IMAGE_PROVIDER=` (e.g. `openrouter`) bypasses the normal + active/default provider resolution for pet generation only. Unknown values are + ignored so existing users are unaffected. + """ + forced = os.environ.get("HERMES_PET_IMAGE_PROVIDER", "").strip().lower() + return forced if forced in _REF_CAPABLE else None + + +class GenerationError(RuntimeError): + """Raised on any image-generation failure (no provider, API error, IO).""" + + +@dataclass(frozen=True) +class SpriteProvider: + """Resolved provider plus whether it can take reference images.""" + + name: str + provider: object + supports_references: bool + + +def _discover() -> None: + try: + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + except Exception as exc: # noqa: BLE001 - discovery is best-effort + logger.debug("image-gen plugin discovery failed: %s", exc) + + +def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider: + """Pick the image provider to use for sprite work. + + Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's + reference-capable and configured, then the configured/active provider when + it's reference-capable, else the first available reference-capable provider. + With *require_references* off we fall back to any available provider (used for + prompt-only base drafts). + """ + _discover() + from agent.image_gen_registry import get_active_provider, get_provider + + # QA override: force one provider for pet-gen iteration regardless of the + # globally active image_gen backend. + forced = _forced_provider_from_env() + if forced: + chosen = get_provider(forced) + if chosen is not None and chosen.is_available(): + return SpriteProvider(name=forced, provider=chosen, supports_references=True) + + # An explicit user pick wins when it's reference-capable and has credentials; + # otherwise we ignore it and fall through to the normal resolution. + if prefer: + chosen = get_provider(prefer) + if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available(): + return SpriteProvider(name=prefer, provider=chosen, supports_references=True) + + # Configured / active provider first. + active = None + try: + active = get_active_provider() + except Exception: # noqa: BLE001 + active = None + if active is not None: + name = getattr(active, "name", "") + if name in _REF_CAPABLE and active.is_available(): + return SpriteProvider(name=name, provider=active, supports_references=True) + + # Any available reference-capable provider. + for name in _REF_CAPABLE: + provider = get_provider(name) + if provider is not None and provider.is_available(): + return SpriteProvider(name=name, provider=provider, supports_references=True) + + if not require_references and active is not None and active.is_available(): + return SpriteProvider( + name=getattr(active, "name", "unknown"), provider=active, supports_references=False + ) + + raise GenerationError( + "Pet generation needs an image backend that supports reference images. " + "Open `hermes tools` → Image Generation and configure Nous Portal, " + "OpenRouter, or OpenAI (gpt-image-2) with an API key." + ) + + +def list_sprite_providers() -> list[dict]: + """The reference-capable providers available to pick for pet generation. + + Returns ``[{name, label, default}]`` for every ref-capable provider the user + actually has credentials for, in preference order, marking the one + :func:`resolve_provider` would choose with no explicit preference. Empty when + none is configured (the picker hides itself). Best-effort: discovery hiccups + yield an empty list. + """ + _discover() + from agent.image_gen_registry import get_provider + + try: + default_name = resolve_provider(require_references=True).name + except GenerationError: + default_name = "" + + out: list[dict] = [] + for name in _REF_CAPABLE: + provider = get_provider(name) + if provider is None or not provider.is_available(): + continue + out.append( + { + "name": name, + "label": _PROVIDER_LABELS.get(name, name), + "default": name == default_name, + } + ) + return out + + +def _save_local(image_ref: str, *, prefix: str) -> Path: + """Return a local path for *image_ref*, downloading it if it's a URL.""" + if image_ref.startswith(("http://", "https://")): + from agent.image_gen_provider import save_url_image + + return Path(save_url_image(image_ref, prefix=prefix)) + return Path(image_ref) + + +def _rejected_background(error: str) -> bool: + """True when a provider error is specifically about the ``background`` param. + + Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers + reject ``background=transparent`` outright). We detect that one rejection so + we can retry without the flag rather than failing the whole pet — our chroma + key pass makes the result transparent regardless. + """ + lowered = (error or "").lower() + return "background" in lowered and ("not supported" in lowered or "transparent" in lowered) + + +def generate( + prompt: str, + *, + n: int = 1, + reference_images: list[Path] | None = None, + provider: SpriteProvider | None = None, + prefix: str = "pet_gen", + aspect_ratio: str = "square", +) -> list[Path]: + """Generate *n* sprite images and return their local paths. + + *reference_images* grounds the output on a base image (required for rows). + *aspect_ratio* picks the canvas: ``"square"`` for single-character base + drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas + gives every frame real horizontal room so winged poses don't have to be + shrunk to avoid touching their neighbors). + We *ask* for a transparent background, but fall back to an opaque generation + (cleaned up downstream by the chroma-key pass) on models that reject the + flag. Raises :class:`GenerationError` if nothing usable comes back. + """ + sprite = provider or resolve_provider(require_references=bool(reference_images)) + if reference_images and not sprite.supports_references: + raise GenerationError( + f"image backend '{sprite.name}' cannot use reference images; " + "configure OpenAI gpt-image-2 or Krea for pet generation" + ) + + refs = [str(p) for p in (reference_images or [])] + + def _run(extra: dict) -> tuple[Path | None, str]: + kwargs: dict = {"aspect_ratio": aspect_ratio, **extra} + if refs: + # Providers disagree on the ref kwarg name: our OpenRouter/Nous + # backends read ``reference_images``, OpenAI's gpt-image-2 reads + # ``reference_image_urls``. Send both; each ignores the other. + kwargs["reference_images"] = refs + kwargs["reference_image_urls"] = refs + try: + result = sprite.provider.generate(prompt, **kwargs) + except Exception as exc: # noqa: BLE001 - normalize provider crashes + logger.debug("provider.generate crashed: %s", exc) + return None, str(exc) + if not isinstance(result, dict) or not result.get("success"): + return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result" + image_ref = result.get("image") + if not image_ref: + return None, "provider returned no image" + try: + return _save_local(str(image_ref), prefix=prefix), "" + except Exception as exc: # noqa: BLE001 + return None, f"could not save generated image: {exc}" + + out: list[Path] = [] + last_error = "" + allow_transparent = True + for _ in range(max(1, n)): + path, err = _run({"background": "transparent"} if allow_transparent else {}) + # Model doesn't support the transparent flag → drop it for this and every + # remaining variant (no point re-probing a capability we just disproved). + if path is None and allow_transparent and _rejected_background(err): + allow_transparent = False + path, err = _run({}) + if path is not None: + out.append(path) + else: + last_error = err + + if not out: + raise GenerationError(last_error or "image generation produced no output") + return out diff --git a/agent/pet/generate/orchestrate.py b/agent/pet/generate/orchestrate.py new file mode 100644 index 00000000000..54a1adf5b07 --- /dev/null +++ b/agent/pet/generate/orchestrate.py @@ -0,0 +1,358 @@ +"""Pet generation orchestration — the base-draft → hatch flow. + +Two steps, mirroring the UX across every surface: + +1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet + look like" variants. Cheap; the user picks one (or retries for a fresh set). +2. :func:`hatch_pet` — takes the chosen base and generates one grounded row + strip per Hermes state, slices each into frames, composes the atlas, validates + it, and writes the pet into the store. + +Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row +calls happen once, on the pet you actually keep) and gives each UI a natural +preview/loading point. +""" + +from __future__ import annotations + +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from agent.pet.generate import atlas, imagegen, prompts +from agent.pet.generate.imagegen import GenerationError, SpriteProvider + +logger = logging.getLogger(__name__) + +# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", ""). +ProgressFn = Callable[[str, str], None] + +# Image generations are independent network calls, so we fan them out instead of +# blocking on each in turn — a hatch is ~8 row calls that would otherwise run +# back-to-back and routinely blow past the client's RPC timeout. Capped so we +# don't hammer the provider's rate limit (one cold call can still be slow). +_MAX_PARALLEL_GENERATIONS = 4 +# How many times to (re)generate a single row before accepting a best-effort +# slice. Early attempts demand clean per-pose gutters; the last is lenient so a +# stubborn row still yields frames instead of dropping out entirely. +_ROW_GEN_ATTEMPTS = 3 +_MIN_FILLED_STATES = 6 +_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"}) + + +@dataclass(frozen=True) +class HatchResult: + """Outcome of a successful :func:`hatch_pet`.""" + + slug: str + display_name: str + spritesheet: Path + states: list[str] + validation: dict + + +def _harden_transparency(path: Path) -> Path: + """Key out any solid backdrop the provider painted; save as an RGBA PNG. + + ``background=transparent`` is requested on every call, but image models honor + it inconsistently — some still paint a flat (often near-white) backdrop. We + run the same chroma-key pass the row extractor uses so every base draft the + user picks between (and the reference the rows are grounded on) is a clean + cutout. Best-effort: a decode failure leaves the original untouched. + """ + from PIL import Image + + try: + with Image.open(path) as opened: + keyed = atlas.remove_background(opened.convert("RGBA")) + # Zero the RGB of any leftover semi-transparent edge pixels so a keyed + # draft has no colored halo when composited on the dark UI. + keyed = atlas._clear_transparent_rgb(keyed) + out = path.with_suffix(".png") + keyed.save(out, format="PNG") + return out + except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image + logger.debug("base draft transparency hardening failed for %s: %s", path, exc) + return path + + +def generate_base_drafts( + concept: str, + *, + n: int = 4, + style: str = "auto", + reference_images: list[Path] | None = None, + provider: SpriteProvider | None = None, + on_draft: Callable[[int, Path], None] | None = None, + is_cancelled: Callable[[], bool] | None = None, +) -> list[Path]: + """Generate *n* candidate base looks for *concept*; returns image paths. + + Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`). + Drafts are generated concurrently and *on_draft(index, path)* fires as each + one finishes (not at the end) so callers can stream previews to the UI + instead of leaving it blank until the whole batch is done. + + *is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't + started yet is skipped, and once it trips we stop staging/streaming further + drafts and cancel any queued work (already-in-flight provider calls can't be + hard-killed, but their results are dropped). + """ + # A user reference image (e.g. their own pet) grounds every draft, so it + # needs a reference-capable provider — same requirement as the row passes. + refs = reference_images or None + sprite = provider or imagegen.resolve_provider(require_references=bool(refs)) + cancelled = is_cancelled or (lambda: False) + + # Each draft is its own one-shot generation, run concurrently so the user + # waits for one image, not N. A single draft failing must not sink the set. + # Each gets a distinct variation nudge so the options aren't near-duplicates. + logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style) + + def _one(index: int) -> tuple[int, Path | None, str | None]: + if cancelled(): + return index, None, None + t0 = time.monotonic() + variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)] + prompt = prompts.build_base_prompt(concept, style=style, variation=variation) + try: + out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base") + except Exception as exc: # noqa: BLE001 - tolerate a single failed draft + logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc) + return index, None, str(exc) + if not out: + logger.warning("pet generate: draft %d produced no image", index) + return index, None, "the image provider returned no image" + logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0) + return index, _harden_transparency(out[0]), None + + workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS)) + results: dict[int, Path] = {} + errors: list[str] = [] + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(_one, i) for i in range(n)] + # as_completed runs in *this* (the caller's) thread, so on_draft — and any + # gateway event it emits — inherits the request's bound transport, unlike + # the worker threads above. + for fut in as_completed(futures): + if cancelled(): + logger.info("pet generate: cancelled — dropping remaining drafts") + for pending in futures: + pending.cancel() + break + index, path, err = fut.result() + if path is None: + if err: + errors.append(err) + continue + results[index] = path + if on_draft is not None: + try: + on_draft(index, path) + except Exception as exc: # noqa: BLE001 - progress is best-effort + logger.debug("on_draft callback failed: %s", exc) + + drafts = [results[i] for i in sorted(results)] + if not drafts and not cancelled(): + # Surface *why* — every draft failed for a reason (a content-policy refusal + # on a name like "minion", a provider/auth error, …); the most common one + # is the representative cause. Far more useful than "no usable drafts". + raise GenerationError(_drafts_failed_reason(errors)) + return drafts + + +def _drafts_failed_reason(errors: list[str]) -> str: + """The representative reason a draft round produced nothing, humanized.""" + if not errors: + return "image generation produced no usable drafts" + from collections import Counter + + return _humanize_image_error(Counter(errors).most_common(1)[0][0]) + + +def _humanize_image_error(error: str) -> str: + """Turn a raw provider error into a friendly, actionable sentence. + + The big one is moderation: image models refuse trademarked characters and + real people (e.g. "minion"), which reads as an opaque 400 otherwise. + """ + low = error.lower() + if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")): + return ( + "The image provider blocked this prompt — its safety filter rejects " + "trademarked characters and real people. Try an original description." + ) + if any(s in low for s in ("api key", "unauthorized", "401", "auth")): + return "The image provider rejected the request — check your API key in Settings → Providers." + if "rate limit" in low or "429" in low: + return "The image provider is rate-limiting — wait a moment and try again." + # Otherwise the first line, trimmed of the noisy provider envelope. + return error.splitlines()[0].strip()[:200] + + +def hatch_pet( + *, + base_image: str | Path, + slug: str, + display_name: str = "", + description: str = "", + concept: str = "", + style: str = "auto", + on_progress: ProgressFn | None = None, + provider: SpriteProvider | None = None, + is_cancelled: Callable[[], bool] | None = None, +) -> HatchResult: + """Turn an approved base image into a full, installed Hermes pet. + + Generates a grounded row strip per state, extracts frames, composes + + validates the atlas, and registers it. The idle row falls back to the base + look so the pet always renders. Raises :class:`GenerationError` on failure. + + *is_cancelled*, when supplied, is polled cooperatively: rows that haven't + started are skipped, queued rows are cancelled, and once every row is done we + abort (raising :class:`GenerationError`) before composing/saving so a stopped + hatch never writes a half-built pet. + """ + base = Path(base_image) + if not base.is_file(): + raise GenerationError(f"base image not found: {base}") + + sprite = provider or imagegen.resolve_provider(require_references=True) + progress = on_progress or (lambda *_: None) + cancelled = is_cancelled or (lambda: False) + label = concept or display_name or slug + + frames_by_state: dict[str, list] = {} + total_rows = len(atlas.ROW_SPECS) + logger.info("pet hatch %r: generating %d animation rows", slug, total_rows) + + # Generate every state's row strip concurrently — they're independent + # grounded calls, so the hatch waits for the slowest row, not their sum. A + # single row failing is tolerated (idle is guaranteed below). + def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]: + state, _row, count = spec + if cancelled(): + return state, None + t0 = time.monotonic() + last_exc: Exception | None = None + # Self-healing: a model occasionally returns a row whose poses are touching + # (no clean gutters), which slices badly. We retry such rolls; only the + # final attempt falls back to lenient ``auto`` slicing so a stubborn row + # still yields *something* rather than dropping the whole row. + for attempt in range(_ROW_GEN_ATTEMPTS): + if cancelled(): + return state, None + strict = attempt < _ROW_GEN_ATTEMPTS - 1 + try: + strips = imagegen.generate( + prompts.build_row_prompt(state, count, label, style=style), + n=1, + reference_images=[base], + provider=sprite, + prefix=f"pet_row_{state}", + # Wider canvas → each frame gets real horizontal room, so winged + # poses keep a full, healthy size and still leave clean gutters. + aspect_ratio="landscape", + ) + # ``components`` requires clean per-pose gutters (raises otherwise), + # so a touching roll is rejected and regenerated; the last attempt + # uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False) + # so normalize_cells registers the whole pet at once. + method = "components" if strict else "auto" + frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False) + logger.info( + "pet hatch %r: row %r ready in %.1fs (attempt %d)", + slug, state, time.monotonic() - t0, attempt + 1, + ) + return state, frames + except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated + last_exc = exc + logger.warning( + "pet hatch %r: row %r attempt %d/%d failed: %s", + slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc, + ) + logger.warning( + "pet hatch %r: row %r gave up after %.1fs: %s", + slug, state, time.monotonic() - t0, last_exc, + ) + return state, None + + # running-left is derived by mirroring running-right (guaranteed-consistent + # and one fewer generation), so we don't generate it directly. + generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"] + + workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS)) + done = 0 + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(_gen_row, spec) for spec in generated_specs] + # as_completed runs on the caller (request) thread, so progress events + # emitted here inherit the request transport — unlike the worker threads. + for fut in as_completed(futures): + if cancelled(): + logger.info("pet hatch %r: cancelled — dropping remaining rows", slug) + for pending in futures: + pending.cancel() + break + state, frames = fut.result() + done += 1 + progress("row", f"{state}:{done}:{total_rows}") + if frames: + frames_by_state[state] = frames + + if cancelled(): + raise GenerationError("hatch cancelled") + + # Derive running-left from the approved running-right row (per-frame mirror, + # preserving order/timing). Missing running-right is rejected below; a pet + # without its canonical walk cycle is a failed hatch, not a shippable mascot. + right = frames_by_state.get("running-right") + if right: + done += 1 + progress("row", f"running-left:{done}:{total_rows}") + frames_by_state["running-left"] = atlas.mirror_frames(right) + logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug) + else: + logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug) + + # Idle is the resting state the renderer falls back to — guarantee it. + if not frames_by_state.get("idle"): + progress("row", "idle-fallback") + frames_by_state["idle"] = [atlas.single_frame(base, fit=False)] + + progress("compose", "") + logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state)) + # One shared scale + baseline across every state so the pet never slides or + # pulses size between frames; compose just packs the normalized cells. + sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state)) + validation = atlas.validate_atlas(sheet) + if not validation["ok"]: + raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed") + filled_states = set(validation["filled_states"]) + missing_required = sorted(_REQUIRED_STATES - filled_states) + if missing_required: + raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}") + if len(filled_states) < _MIN_FILLED_STATES: + raise GenerationError( + f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate" + ) + + from agent.pet import store + + progress("save", slug) + logger.info("pet hatch %r: saving pet", slug) + pet = store.register_local_pet( + sheet, + slug=slug, + display_name=display_name or slug, + description=description, + ) + return HatchResult( + slug=pet.slug, + display_name=pet.display_name, + spritesheet=pet.spritesheet, + states=validation["filled_states"], + validation=validation, + ) diff --git a/agent/pet/generate/prompts.py b/agent/pet/generate/prompts.py new file mode 100644 index 00000000000..085f8a05fc6 --- /dev/null +++ b/agent/pet/generate/prompts.py @@ -0,0 +1,183 @@ +"""Prompt builders for pet generation. + +Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the +user picks between) and per-*state* *row* prompts (grounded on the chosen base, +produce one horizontal strip of N poses). Prompts stay concise and +sprite-production oriented; the identity lock and "one transparent row" framing +matter more than flowery description. + +We generate the full petdex/Codex nine-state set (see +:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid +``petdex submit`` spritesheet. +""" + +from __future__ import annotations + +# What each petdex/Codex state should depict (kept short — these go straight into +# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached +# effects, motion lines, shadows). Critical distinction: ``running`` is the +# *working* state (in place), while ``running-right`` / ``running-left`` are the +# actual directional walk/run cycles. +STATE_ACTIONS: dict[str, str] = { + "idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures", + "running-right": ( + "a sideways walk/run locomotion cycle moving to the RIGHT: the character " + "faces and travels right with clear directional steps, a smooth gait loop" + ), + "running-left": ( + "a sideways walk/run locomotion cycle moving to the LEFT: the character " + "faces and travels left with clear directional steps (the mirror of the " + "right-facing run)" + ), + "waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture", + "jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land", + "failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy", + "waiting": ( + "an expectant 'waiting on you' pose: looking up/out as if asking for input " + "or approval — distinct from idle and review" + ), + "running": ( + "focused active work, staying IN PLACE (NOT walking or foot-running): " + "leaning in, concentrating, busy 'thinking / processing / typing' energy" + ), + "review": "careful inspection: a focused lean, head tilt, studying something intently", +} + +_STYLE_HINTS: dict[str, str] = { + # Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth + # 2D illustration (let alone 3D render) gpt-image reaches for by default. + "auto": ( + " Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small " + "limited palette, clean dark outline, flat cel shading, chunky chibi " + "proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. " + "Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, " + "NOT photorealistic — no soft gradients, no realistic lighting, no figurine look." + ), + "pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.", + "plush": " Render as a soft plush toy.", + "clay": " Render as a claymation / soft 3D clay figure.", + "sticker": " Render as a glossy die-cut sticker.", + "flat-vector": " Render in flat vector mascot style.", + "3d-toy": " Render as a glossy 3D toy.", + "painterly": " Render in a soft painterly style.", +} + +_BACKGROUND = ( + "Center the character on a SINGLE flat, uniform, high-contrast chroma-key " + "background — pure hot magenta #FF00FF (only if magenta appears on the " + "character, use pure green #00FF00 instead). The background is ONE continuous " + "even color that completely surrounds the character with NO gradient, " + "vignette, texture, pattern, scenery, shadow, ground line, frame, border, " + "panel, comic cell, gutter line, grid, or divider of any kind, so it keys out " + "cleanly. The background color must not appear anywhere on the character. " + "No text, no labels, no speech bubbles, no UI." +) + + +def style_hint(style: str | None) -> str: + return _STYLE_HINTS.get((style or "auto").strip().lower(), "") + + +# Row strips are generated on the wider landscape canvas (see imagegen.generate / +# orchestrate). The extra width is what lets each pose stay a healthy size AND +# leave a real gutter — used here only to cite concrete pixel numbers. +_ASSUMED_STRIP_WIDTH = 1536 + + +def _spacing_spec(frame_count: int) -> tuple[int, int]: + """(per-pose width px, gap px) for a row of *frame_count* poses. + + Pixel counts alone don't hold — the model fills each slot edge-to-edge with + the full wingspan, so neighbors touch even when bodies are spaced. The lever + that works is proportional containment on a wide canvas: give each pose its + own equal cell and keep the ENTIRE silhouette (wings/tail/halo included) + inside it. On the 1536px landscape strip ~70% occupancy still leaves a + generous gutter, so the pet stays a normal, good-looking size — no shrinking. + """ + slots = max(1, frame_count) + slot_w = _ASSUMED_STRIP_WIDTH / slots + pose_px = round(slot_w * 0.7) + gap_px = max(48, round(slot_w * 0.3)) + return pose_px, gap_px + + +# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns +# near-duplicates for a single prompt. We vary the *look* (palette, build, +# expression, accents), NOT the pose, so the chosen base still grounds clean, +# consistent animation rows. +BASE_VARIATIONS: tuple[str, ...] = ( + "", + "a distinctly different colour palette and markings", + "a heavier, broader silhouette with sturdier proportions", + "a different facial structure and expression matching the concept tone, with unique accent/accessory details", + "a leaner, taller build and an alternate colour scheme", + "bolder, more saturated colours and a stronger expression matching the concept tone", +) + + +def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str: + """The base look: a single, clean, centered full-body mascot. + + *variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`). + """ + concept = (concept or "a distinctive mascot creature").strip() + nudge = f" Make this design distinct: {variation}." if variation else "" + return ( + f"A stylized mascot pet character: {concept}. " + "Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) " + "while staying non-graphic. " + "Compact, whole-body silhouette that reads clearly at small size, " + "clear readable facial features, simple consistent palette. " + # A neutral, symmetric, at-rest stance makes the cleanest identity anchor + "Neutral front-facing standing pose, upright and symmetric, arms/limbs " + "relaxed at the sides, feet together on the ground, any cape/accessories " + "hanging straight and still." + f"{nudge} " + f"{_BACKGROUND}{style_hint(style)}" + ) + + +def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str: + """A row strip: *frame_count* poses of the SAME character, left→right. + + The attached base image is the identity source of truth; the prompt locks + species, palette, face, and props to it. + """ + action = STATE_ACTIONS.get(state, "a simple idle pose") + concept = (concept or "the mascot").strip() + pose_px, gap_px = _spacing_spec(frame_count) + return ( + f"Using the attached reference image as the exact same character " + f"(same species, face, colors, markings, proportions, and props), " + "preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), " + f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. " + f"LAYOUT: arrange {frame_count} poses in ONE horizontal row at equal spacing, " + "each pose centered in its own imaginary equal region. Draw NO panel borders, " + "NO comic cells, NO boxes, NO vertical divider/gutter lines, NO grid, NO frame " + "outlines between poses — the backdrop is one unbroken flat field behind all of them. " + "Fill the WHOLE strip with the SAME single flat chroma-key color as the attached " + "reference image's background (identical hue in every frame, no per-pose color shifts). " + f"SPACING (critical): draw each pose at a consistent, healthy, clearly " + f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px " + f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette " + f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own " + f"cell. Leave at least {gap_px}px of empty chroma-key background between " + f"neighboring silhouettes at their closest point (wingtip to wingtip), and " + f"the same empty margin before the first pose and after the last. If a wing, " + f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather " + f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, " + f"share a shadow, share a ground line, share motion trails, or merge into " + f"one connected shape. " + # Registration: a clean sprite sheet keeps the character locked in place + # so only the action moves — this is what stops the loop sliding/pulsing. + "REGISTRATION (critical): the character is the SAME height and SAME width " + "in every frame, drawn at the SAME scale, centered over the SAME point, " + "with all feet aligned to the SAME invisible horizontal baseline across the " + "whole strip — this baseline is conceptual ONLY: draw NO ground line, floor, " + "platform, horizon, or contact shadow beneath the feet. Keep the body's center, size, and stance fixed frame to " + "frame — ONLY the limbs/features the action needs may move. Capes, cloaks, " + "bags, and scarves stay in the SAME place and shape every frame (no " + "swinging, flowing, or drifting) unless the action itself requires it. No " + "pose is cropped at the strip edges. " + f"{_BACKGROUND}{style_hint(style)}" + ) diff --git a/agent/pet/manifest.py b/agent/pet/manifest.py new file mode 100644 index 00000000000..98a0e4a3f7e --- /dev/null +++ b/agent/pet/manifest.py @@ -0,0 +1,165 @@ +"""Fetch the public petdex manifest. + +``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2: + + { + "generatedAt": "...", + "total": 2926, + "pets": [ + {"slug": "boba", "displayName": "Boba", "kind": "creature", + "submittedBy": "railly", + "spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp", + "petJsonUrl": "https://assets.petdex.dev/.../pet.json", + "zipUrl": "https://assets.petdex.dev/.../boba.zip"}, + ... + ] + } + +Read-only and unauthenticated; no credentials involved. +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +MANIFEST_URL = "https://petdex.dev/api/manifest" + +_DEFAULT_TIMEOUT = 10.0 + +# In-process cache for the (large, slow, identical-per-call) manifest. The list +# is a static CDN object that barely changes, yet a single session can ask for +# it many times — every gallery open, plus a full re-fetch per install/select +# (``find_entry``). A short TTL collapses those into one network hit without +# going stale for long. Cleared by :func:`clear_cache` (tests). +_MANIFEST_TTL = 300.0 +_cache: tuple[float, list[ManifestEntry]] | None = None + +_prefetch_lock = threading.Lock() +_prefetching = False + + +def clear_cache() -> None: + """Drop the cached manifest (forces the next fetch to hit the network).""" + global _cache + _cache = None + + +def _cache_is_warm() -> bool: + return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL + + +def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None: + """Warm the manifest cache in a daemon thread — idempotent, never blocks. + + The desktop picker calls this when it loads the (instant) local-only gallery + so the full petdex catalog is usually cached by the time it's requested, + without ever holding up the user's own pets on a network round-trip. + """ + global _prefetching + + if _cache_is_warm(): + return + + with _prefetch_lock: + if _prefetching: + return + _prefetching = True + + def _run() -> None: + global _prefetching + try: + fetch_manifest(timeout=timeout) + except Exception as exc: # noqa: BLE001 - best-effort warm + logger.debug("petdex manifest prefetch failed: %s", exc) + finally: + _prefetching = False + + threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start() + + +@dataclass(frozen=True) +class ManifestEntry: + """A single pet's row in the manifest.""" + + slug: str + display_name: str + kind: str + submitted_by: str + spritesheet_url: str + pet_json_url: str + zip_url: str + + @classmethod + def from_dict(cls, data: dict) -> "ManifestEntry": + return cls( + slug=str(data.get("slug", "")).strip(), + display_name=str(data.get("displayName", "") or data.get("slug", "")), + kind=str(data.get("kind", "") or "pet"), + submitted_by=str(data.get("submittedBy", "") or ""), + spritesheet_url=str(data.get("spritesheetUrl", "") or ""), + pet_json_url=str(data.get("petJsonUrl", "") or ""), + zip_url=str(data.get("zipUrl", "") or ""), + ) + + +class ManifestError(RuntimeError): + """Raised when the manifest can't be fetched or parsed.""" + + +def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]: + """Return every approved pet from the public manifest. + + Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to + bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on + any network/parse failure so callers can surface a clean message. + """ + global _cache + + if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL: + return _cache[1] + + try: + import httpx + except ImportError as exc: # pragma: no cover - httpx is a core dep + raise ManifestError("httpx is required to fetch the petdex manifest") from exc + + try: + resp = httpx.get( + MANIFEST_URL, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + payload = resp.json() + except Exception as exc: # noqa: BLE001 - normalize to one error type + raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc + + pets = payload.get("pets") if isinstance(payload, dict) else None + if not isinstance(pets, list): + raise ManifestError("petdex manifest had no 'pets' array") + + entries: list[ManifestEntry] = [] + for raw in pets: + if not isinstance(raw, dict): + continue + entry = ManifestEntry.from_dict(raw) + if entry.slug and entry.spritesheet_url: + entries.append(entry) + + _cache = (time.monotonic(), entries) + return entries + + +def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None: + """Return the manifest entry for *slug*, or ``None`` if not listed.""" + slug = slug.strip().lower() + for entry in fetch_manifest(timeout=timeout): + if entry.slug.lower() == slug: + return entry + return None diff --git a/agent/pet/render.py b/agent/pet/render.py new file mode 100644 index 00000000000..1618c0751d2 --- /dev/null +++ b/agent/pet/render.py @@ -0,0 +1,618 @@ +"""Decode a pet spritesheet and encode frames for a terminal. + +Shared by the base CLI (writes the escape bytes to its own stdout) and the +TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the +decode + capability-detection + protocol-encoding logic exists exactly once. + +Supported output modes, in fidelity order: + +- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm). +- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm). +- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …). +- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal. + +Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the +spritesheet is unavailable the renderer degrades to ``unicode`` text or an +empty string rather than raising. +""" + +from __future__ import annotations + +import base64 +import io +import logging +import os +import sys +from functools import lru_cache +from pathlib import Path + +from agent.pet.constants import ( + DEFAULT_SCALE, + FRAME_H, + FRAME_W, + FRAMES_PER_STATE, + PetState, + state_row_index, +) + +logger = logging.getLogger(__name__) + +# Public render-mode names accepted by ``display.pet.render_mode``. +RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off") + + +# ───────────────────────────────────────────────────────────────────────── +# Terminal capability detection +# ───────────────────────────────────────────────────────────────────────── + +def detect_terminal_graphics() -> str: + """Best-effort detection of the richest graphics protocol available. + + Env-based (non-blocking — we never issue a DA1/terminal query that could + hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` / + ``unicode``. Conservative: unknown terminals get ``unicode``, which works + anywhere with truecolor. + """ + term = os.environ.get("TERM", "").lower() + term_program = os.environ.get("TERM_PROGRAM", "").lower() + + # The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode + # authoritatively but does NOT scrub the terminal env vars it inherits when + # launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …). + # Trusting those leaks emits an image protocol the embedded xterm.js can't + # display — you get a blank frame. Inline images there are opt-in + # (terminal.integrated.enableImages), so default to half-blocks, which + # always render in its truecolor grid. Users who enabled images can pin + # display.pet.render_mode explicitly. + if term_program == "vscode": + return "unicode" + + # kitty graphics protocol + if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term: + return "kitty" + if term_program in {"ghostty"}: + return "kitty" + + # WezTerm speaks both kitty and iterm; prefer kitty (richer placement). + if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"): + return "kitty" + + # iTerm2 inline images + if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"): + return "iterm" + + # sixel-capable terminals (env heuristics only) + if term_program in {"mintty"} or "foot" in term or "mlterm" in term: + return "sixel" + if "sixel" in term: + return "sixel" + + return "unicode" + + +def resolve_mode(configured: str | None, *, stream=None) -> str: + """Resolve the effective render mode from config + the environment. + + ``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns + ``off`` when not attached to a TTY (no point emitting graphics into a pipe + or logfile). + """ + mode = (configured or "auto").strip().lower() + if mode not in RENDER_MODES: + mode = "auto" + if mode == "off": + return "off" + + stream = stream or sys.stdout + try: + if not (hasattr(stream, "isatty") and stream.isatty()): + return "off" + except (ValueError, OSError): + return "off" + + if mode == "auto": + return detect_terminal_graphics() + return mode + + +# ───────────────────────────────────────────────────────────────────────── +# Frame decoding +# ───────────────────────────────────────────────────────────────────────── + +def _open_sheet(path: Path): + from PIL import Image + + img = Image.open(path) + return img.convert("RGBA") + + +# Max alpha at/below which a frame counts as blank padding. petdex sheets are +# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills +# the trailing columns with fully transparent cells. Animating into one flashes +# the pet blank, so we stop the row at the first such gap. +_BLANK_ALPHA = 8 + + +def _frame_is_blank(frame) -> bool: + """True if *frame* has no meaningfully opaque pixel (transparent padding).""" + return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA + + +@lru_cache(maxsize=16) +def _raw_frames( + sheet_path: str, + state_value: str, + frame_w: int, + frame_h: int, + frames_per_state: int, +) -> tuple: + """Cropped, padding-trimmed RGBA frames for one state row (unscaled). + + Steps across the row until the first blank column so pets with ragged + per-state frame counts never animate into empty padding. Cached; returns + ``()`` on any decode failure. + """ + try: + sheet = _open_sheet(Path(sheet_path)) + cols = max(1, sheet.width // frame_w) + rows = max(1, sheet.height // frame_h) + row = state_row_index(state_value, rows) + top = row * frame_h + # Clamp the row to the sheet (some pets ship fewer rows than the 8 the + # taxonomy reserves). + if top + frame_h > sheet.height: + top = max(0, sheet.height - frame_h) + + frames = [] + for i in range(min(frames_per_state, cols)): + left = i * frame_w + frame = sheet.crop((left, top, left + frame_w, top + frame_h)) + if _frame_is_blank(frame): + break # trailing transparent padding — real frames end here + frames.append(frame) + return tuple(frames) + except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal + logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc) + return () + + +@lru_cache(maxsize=8) +def _frames_for( + sheet_path: str, + state_value: str, + frame_w: int, + frame_h: int, + frames_per_state: int, + scale_w: int, + scale_h: int, +): + """Return padding-trimmed RGBA frames for one state row, scaled. + + Thin scaling layer over :func:`_raw_frames`; both are cached so repeated + frame requests during animation are free. + """ + raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state) + if not raw or (scale_w, scale_h) == (frame_w, frame_h): + return list(raw) + from PIL import Image + + return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw] + + +def state_frame_counts( + sheet_path: str | Path, + *, + frame_w: int = FRAME_W, + frame_h: int = FRAME_H, + frames_per_state: int = FRAMES_PER_STATE, +) -> dict[str, int]: + """Map each driven :class:`PetState` → its real (padding-trimmed) frame count. + + The single source of truth for "how many frames does this state actually + have?". The CLI/TUI consume the trimmed frame lists directly; the gateway + ships this map to the desktop canvas, which steps its own loop. + """ + return { + state.value: len( + _raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state) + ) + for state in PetState + } + + +# ───────────────────────────────────────────────────────────────────────── +# Encoders +# ───────────────────────────────────────────────────────────────────────── + +def _png_bytes(frame) -> bytes: + buf = io.BytesIO() + frame.save(buf, format="PNG") + return buf.getvalue() + + +def _kitty_apc(ctrl: str, data: str) -> str: + """Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces.""" + chunk = 4096 + if len(data) <= chunk: + return f"\x1b_G{ctrl},m=0;{data}\x1b\\" + out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"] + rest = data[chunk:] + while rest: + piece, rest = rest[:chunk], rest[chunk:] + out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\") + return "".join(out) + + +def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: + """Encode one frame via the kitty graphics protocol (transmit + display). + + ``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display + box in terminal cells so successive frames overwrite the same area. + """ + ctrl = "f=100,a=T,q=2" + if cell_cols: + ctrl += f",c={cell_cols}" + if cell_rows: + ctrl += f",r={cell_rows}" + return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) + + +# ───────────────────────────────────────────────────────────────────────── +# kitty Unicode placeholders +# +# Ink (the TUI's React-for-terminal layer) owns the screen and measures every +# cell's width, so it can't host raw kitty image escapes (no width to count, +# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the +# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the +# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose +# foreground color encodes the image id. Ink counts those as width-1 text, so +# layout stays correct and the terminal paints the image underneath. +# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders +# ───────────────────────────────────────────────────────────────────────── + +_KITTY_PLACEHOLDER = "\U0010eeee" + +# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's +# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is +# the diacritic that encodes the number i; we only ever need the row index. +_ROWCOL_DIACRITICS: tuple[int, ...] = ( + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, + 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, + 0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, + 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, + 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9, + 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, + 0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, + 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, + 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A, + 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, + 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, + 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, + 0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, + 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, + 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6, + 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, + 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, + 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, + 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, + 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, + 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, + 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, + 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, + 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, + 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23, + 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, + 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244, +) + + +def kitty_image_id(slug: str) -> int: + """Stable per-pet image id in ``[1, 0x7FFF]``. + + The id is encoded in the placeholder's 24-bit foreground color, so it must + be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it + deterministic per slug (so re-renders reuse the same terminal-side image) + while making collisions between two different pets unlikely. + """ + import zlib + + return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1 + + +def kitty_color_hex(image_id: int) -> str: + """Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty.""" + return "#%06x" % (image_id & 0xFFFFFF) + + +def kitty_placeholder_rows(cols: int, rows: int) -> list[str]: + """Build the placeholder text grid for an *rows*×*cols* image. + + Each line is one row of the grid: the first cell carries the row diacritic + (column defaults to 0), and the remaining ``cols-1`` bare placeholders let + the terminal auto-increment the column. The foreground color (the image id) + is applied by the caller / Ink, not embedded here. + """ + cols = max(1, cols) + out: list[str] = [] + for r in range(max(1, rows)): + idx = min(r, len(_ROWCOL_DIACRITICS) - 1) + first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx]) + out.append(first + _KITTY_PLACEHOLDER * (cols - 1)) + return out + + +def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str: + """Transmit a frame as a kitty *virtual* placement for Unicode placeholders. + + ``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it + virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the + terminal's OK/error replies that would otherwise corrupt the host app's + output. Re-sending with the same ``i`` replaces the image, so the static + placeholder cells animate underneath. + """ + ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2" + return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) + + +def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: + """Encode one frame as an iTerm2 inline image (OSC 1337 File).""" + payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii") + size = len(payload) + args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"] + if cell_cols: + args.append(f"width={cell_cols}") + if cell_rows: + args.append(f"height={cell_rows}") + return f"\x1b]1337;File={';'.join(args)}:{payload}\x07" + + +def _encode_sixel(frame) -> str: + """Encode one frame as DEC sixel. + + Quantizes to an adaptive palette (≤255 colors) and emits the sixel band + stream. Pillow has no sixel writer, so this is a compact hand-rolled + encoder. Transparent pixels render as background (color register skipped). + """ + from PIL import Image + + rgba = frame + # Composite onto transparent-as-skip: track alpha to decide background. + pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT) + palette = pal.getpalette() or [] + px = pal.load() + alpha = rgba.getchannel("A").load() + w, h = pal.size + + out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)] + # Color register definitions (sixel uses 0..100 scale). + used = sorted({px[x, y] for y in range(h) for x in range(w)}) + for idx in used: + r = palette[idx * 3] if idx * 3 < len(palette) else 0 + g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0 + b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0 + out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255)) + + # Emit in 6-row bands. + for band in range(0, h, 6): + for color_idx in used: + line = ["#%d" % color_idx] + run_char = None + run_len = 0 + + def flush(): + nonlocal run_char, run_len + if run_char is None: + return + if run_len > 3: + line.append("!%d%s" % (run_len, run_char)) + else: + line.append(run_char * run_len) + run_char, run_len = None, 0 + + for x in range(w): + bits = 0 + for bit in range(6): + y = band + bit + if y < h and alpha[x, y] > 32 and px[x, y] == color_idx: + bits |= 1 << bit + ch = chr(63 + bits) + if ch == run_char: + run_len += 1 + else: + flush() + run_char, run_len = ch, 1 + flush() + out.append("".join(line) + "$") # carriage return within band + out.append("-") # next band + out.append("\x1b\\") + return "".join(out) + + +_HALF_BLOCK = "▀" + +# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples. +Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]] + + +def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]: + """Downscale a frame to a grid of half-block cells. + + Each cell pairs a top and bottom pixel so one terminal row encodes two + pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the + framework-neutral representation shared by the ANSI encoder (CLI) and the + structured ``cells`` API (Ink). + """ + from PIL import Image + + target_cols = max(4, target_cols) + aspect = frame.height / max(1, frame.width) + target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2) + small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA") + px = small.load() + + grid: list[list[Cell]] = [] + for y in range(0, target_rows, 2): + row: list[Cell] = [] + for x in range(target_cols): + top = px[x, y] + bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0) + row.append((top, bottom)) + grid.append(row) + return grid + + +def _encode_unicode(frame, *, target_cols: int) -> str: + """Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels).""" + lines: list[str] = [] + for row in _downscale_cells(frame, target_cols=target_cols): + cells: list[str] = [] + for (tr, tg, tb, ta), (br, bg, bb, ba) in row: + if ta < 32 and ba < 32: + cells.append("\x1b[0m ") # fully transparent → blank + continue + cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}") + lines.append("".join(cells) + "\x1b[0m") + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────────────────── +# Public renderer +# ───────────────────────────────────────────────────────────────────────── + +class PetRenderer: + """Holds a pet's spritesheet and yields encoded frames per (state, index). + + Construct once per pet, then call :meth:`frame` on an animation timer. + Cheap to call repeatedly — decoded frames are cached. + """ + + def __init__( + self, + spritesheet: str | Path, + *, + mode: str = "unicode", + scale: float = DEFAULT_SCALE, + unicode_cols: int = 20, + frame_w: int = FRAME_W, + frame_h: int = FRAME_H, + frames_per_state: int = FRAMES_PER_STATE, + ) -> None: + self.spritesheet = str(spritesheet) + self.mode = mode if mode in RENDER_MODES else "unicode" + self.scale = scale + self.unicode_cols = unicode_cols + self.frame_w = frame_w + self.frame_h = frame_h + self.frames_per_state = frames_per_state + + @property + def available(self) -> bool: + return self.mode != "off" and Path(self.spritesheet).is_file() + + def frame_count(self, state: PetState | str) -> int: + return len(self._frames(state)) + + def _frames(self, state: PetState | str): + value = state.value if isinstance(state, PetState) else str(state) + scale_w = max(1, int(self.frame_w * self.scale)) + scale_h = max(1, int(self.frame_h * self.scale)) + return _frames_for( + self.spritesheet, + value, + self.frame_w, + self.frame_h, + self.frames_per_state, + scale_w, + scale_h, + ) + + def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]: + """Return one frame as a half-block cell grid (framework-neutral). + + Used by the TUI, which renders the grid with native Ink color props + instead of raw ANSI. Returns ``[]`` when no frame is available. + """ + frames = self._frames(state) + if not frames: + return [] + frame = frames[index % len(frames)] + return _downscale_cells(frame, target_cols=cols or self.unicode_cols) + + def _cell_box(self, frame) -> tuple[int, int]: + """Terminal cell box for a scaled frame (~8×16 px per cell). + + Must match :meth:`frame` graphics sizing — kitty stretches the image to + fill ``c``×``r`` cells, so these must reflect the scaled pixel + dimensions, not a native-aspect column count (that upscales small pets). + """ + return max(1, frame.width // 8), max(1, frame.height // 16) + + def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None: + """Build the kitty Unicode-placeholder payload for one state. + + Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a + list of transmit escapes (one per animation frame, all reusing + ``image_id``) and ``placeholder`` is the static text grid Ink paints. + Placement geometry is derived from the scaled frame pixels (via + :meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill + ``c``×``r`` cells. ``None`` when no frame is available. + """ + frames = self._frames(state) + if not frames: + return None + cols, rows = self._cell_box(frames[0]) + return { + "cols": cols, + "rows": rows, + "placeholder": kitty_placeholder_rows(cols, rows), + "frames": [ + _encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames + ], + } + + def frame(self, state: PetState | str, index: int) -> str: + """Return the encoded escape string for one frame, or ``""``. + + ``index`` is taken modulo the available frame count so callers can pass + a free-running counter. + """ + if self.mode == "off": + return "" + frames = self._frames(state) + if not frames: + return "" + frame = frames[index % len(frames)] + cell_cols, cell_rows = self._cell_box(frame) + + try: + if self.mode == "kitty": + return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows) + if self.mode == "iterm": + return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows) + if self.mode == "sixel": + return _encode_sixel(frame) + return _encode_unicode(frame, target_cols=self.unicode_cols) + except Exception as exc: # noqa: BLE001 - degrade silently + logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc) + return "" + + +def build_renderer( + spritesheet: str | Path, + *, + configured_mode: str | None = None, + scale: float = DEFAULT_SCALE, + unicode_cols: int = 20, + stream=None, +) -> PetRenderer: + """Convenience factory: resolve the mode from config+env, then construct.""" + mode = resolve_mode(configured_mode, stream=stream) + return PetRenderer( + spritesheet, + mode=mode, + scale=scale, + unicode_cols=unicode_cols, + ) diff --git a/agent/pet/state.py b/agent/pet/state.py new file mode 100644 index 00000000000..a9ad5afd801 --- /dev/null +++ b/agent/pet/state.py @@ -0,0 +1,81 @@ +"""Map agent activity → a :class:`PetState`. + +This is the one place the "what is the agent doing right now?" → "which +animation row?" decision lives. Each surface feeds it the signals it already +tracks: + +- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes. +- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events. +- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores + (re-implemented in TS, but mirroring this priority order). + +Keeping the priority order here (and documenting it) lets the TypeScript +mirror stay faithful without a second design. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from agent.pet.constants import PetState + + +def todos_all_done(todos: Iterable[Any] | None) -> bool: + """True iff there's ≥1 todo and every one is completed/cancelled. + + The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors + the TUI's ``isTodoDone`` so the trigger is defined once across surfaces. + Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr. + """ + items = list(todos or []) + if not items: + return False + + def _status(t: Any) -> Any: + return t.get("status") if isinstance(t, dict) else getattr(t, "status", None) + + return all(_status(t) in ("completed", "cancelled") for t in items) + + +def derive_pet_state( + *, + busy: bool = False, + awaiting_input: bool = False, + error: bool = False, + celebrate: bool = False, + just_completed: bool = False, + tool_running: bool = False, + reasoning: bool = False, +) -> PetState: + """Resolve the animation state from coarse activity signals. + + Priority (highest first) — only one row can show at a time, so the most + salient signal wins: + + 1. ``error`` → ``FAILED`` (a tool/turn just failed) + 2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done) + 3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting) + 4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval + prompt is open; this outranks the in-flight signals below because the turn + is paused on *you*, even though a tool is technically mid-call) + 5. ``tool_running`` → ``RUN`` (a tool is executing) + 6. ``reasoning`` → ``REVIEW`` (model is thinking / reading) + 7. ``busy`` → ``RUN`` (turn in flight, unspecified work) + 8. otherwise → ``IDLE`` + """ + if error: + return PetState.FAILED + if celebrate: + return PetState.JUMP + if just_completed: + return PetState.WAVE + if awaiting_input: + return PetState.WAITING + if tool_running: + return PetState.RUN + if reasoning: + return PetState.REVIEW + if busy: + return PetState.RUN + return PetState.IDLE diff --git a/agent/pet/store.py b/agent/pet/store.py new file mode 100644 index 00000000000..42627c1ac81 --- /dev/null +++ b/agent/pet/store.py @@ -0,0 +1,503 @@ +"""On-disk pet store — install / list / resolve pets. + +Pets live under ``get_hermes_home()/pets//`` so every profile gets its +own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default — +that's owned by the petdex npm CLI and isn't profile-aware). Each installed +pet directory holds: + + pets// + pet.json # {id, displayName, description, spritesheetPath} + spritesheet.webp # (or .png) + +The active pet is resolved from the caller-supplied ``display.pet.slug`` config +value (falling back to the first installed pet), so this module stays free of +the config loader. +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +_DOWNLOAD_TIMEOUT = 60.0 + + +class PetStoreError(RuntimeError): + """Raised on install/IO failures.""" + + +@dataclass(frozen=True) +class InstalledPet: + """A pet present on disk.""" + + slug: str + display_name: str + description: str + directory: Path + spritesheet: Path + created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs + + @property + def exists(self) -> bool: + return self.spritesheet.is_file() + + @property + def generated(self) -> bool: + return self.created_by == "generator" + + +def pets_dir() -> Path: + """Return the profile-scoped pets directory (created on demand).""" + path = get_hermes_home() / "pets" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _read_pet_json(directory: Path) -> dict: + pet_json = directory / "pet.json" + if not pet_json.is_file(): + return {} + try: + return json.loads(pet_json.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.debug("unreadable pet.json in %s: %s", directory, exc) + return {} + + +def _resolve_spritesheet(directory: Path, meta: dict) -> Path: + """Find the spritesheet for a pet dir. + + Honors ``spritesheetPath`` from pet.json, else probes the conventional + filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``). + """ + declared = str(meta.get("spritesheetPath", "") or "").strip() + if declared: + candidate = directory / declared + if candidate.is_file(): + return candidate + for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"): + candidate = directory / name + if candidate.is_file(): + return candidate + # Default expectation even if missing, so callers get a stable path. + return directory / "spritesheet.webp" + + +def _safe_slug(slug: str) -> str: + """Normalize a slug to a single bare path segment. + + Pet slugs index into ``pets_dir()//`` for load/remove, so a value + carrying path separators (``../``, absolute paths) could escape the pets + directory. Strip every separator and reject ``.``/``..`` so callers can + only ever name a direct child of the pets directory. + """ + segment = Path(str(slug).strip()).name + if segment in ("", ".", ".."): + return "" + return segment + + +def load_pet(slug: str) -> InstalledPet | None: + """Return the :class:`InstalledPet` for *slug*, or ``None`` if absent.""" + slug = _safe_slug(slug) + if not slug: + return None + directory = pets_dir() / slug + if not directory.is_dir(): + return None + meta = _read_pet_json(directory) + return InstalledPet( + slug=slug, + display_name=str(meta.get("displayName", "") or slug), + description=str(meta.get("description", "") or ""), + directory=directory, + spritesheet=_resolve_spritesheet(directory, meta), + created_by=str(meta.get("createdBy", "") or ""), + ) + + +def installed_pets() -> list[InstalledPet]: + """Return every installed pet (dirs containing a usable spritesheet).""" + out: list[InstalledPet] = [] + for child in sorted(pets_dir().iterdir()): + if not child.is_dir(): + continue + pet = load_pet(child.name) + if pet and pet.exists: + out.append(pet) + return out + + +def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None: + """Resolve which pet to display. + + Precedence: the configured slug (``display.pet.slug``) if it's installed, + otherwise the first installed pet alphabetically, otherwise ``None``. + """ + if configured_slug: + pet = load_pet(configured_slug.strip()) + if pet and pet.exists: + return pet + pets = installed_pets() + return pets[0] if pets else None + + +def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet: + """Download *slug* from the manifest into the pets directory. + + Idempotent: a fully-installed pet is returned as-is unless *force*. Raises + :class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on + failure. + """ + from agent.pet.manifest import find_entry + + slug = _safe_slug(slug) + if not slug: + raise PetStoreError("invalid pet slug") + existing = load_pet(slug) + if existing and existing.exists and not force: + return existing + + entry = find_entry(slug, timeout=timeout) + if entry is None: + raise PetStoreError(f"pet '{slug}' is not in the petdex manifest") + + # Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from + # petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest + # can't redirect the download at an arbitrary host. Matches thumbnail_png. + if not _is_petdex_host(entry.spritesheet_url): + raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'") + + directory = pets_dir() / slug + directory.mkdir(parents=True, exist_ok=True) + + sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp" + sprite_path = directory / f"spritesheet{sprite_ext}" + + _download(entry.spritesheet_url, sprite_path, timeout=timeout) + + # Fetch the upstream pet.json if present; otherwise synthesize a minimal + # one so the local layout is self-describing. + meta: dict = {} + if entry.pet_json_url and _is_petdex_host(entry.pet_json_url): + try: + meta = _download_json(entry.pet_json_url, timeout=timeout) + except Exception as exc: # noqa: BLE001 - non-fatal, fall back below + logger.debug("pet.json fetch failed for %s: %s", slug, exc) + if not isinstance(meta, dict) or not meta: + meta = {"id": slug, "displayName": entry.display_name, "description": ""} + meta["spritesheetPath"] = sprite_path.name + meta.setdefault("id", slug) + meta.setdefault("displayName", entry.display_name) + (directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") + + pet = load_pet(slug) + if pet is None or not pet.exists: + raise PetStoreError(f"install of '{slug}' did not produce a spritesheet") + return pet + + +def slugify(name: str) -> str: + """Lowercase, hyphenate, and strip a display name into a filesystem slug.""" + slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-") + return slug or "pet" + + +def unique_slug(name: str) -> str: + """A :func:`slugify` result that doesn't collide with an existing pet dir.""" + base = slugify(name) + slug = base + counter = 2 + while (pets_dir() / slug).exists(): + slug = f"{base}-{counter}" + counter += 1 + return slug + + +def _write_spritesheet(source, dest: Path) -> None: + """Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*.""" + if isinstance(source, (bytes, bytearray)): + dest.write_bytes(bytes(source)) + return + + from PIL import Image + + if isinstance(source, (str, Path)): + with Image.open(source) as opened: + image = opened.convert("RGBA") + else: + image = source.convert("RGBA") + image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True) + + +def register_local_pet( + spritesheet, + *, + slug: str, + display_name: str = "", + description: str = "", +) -> InstalledPet: + """Write a locally-generated pet into the store and return it. + + *spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet + appears in :func:`installed_pets` immediately, and because :func:`install_pet` + returns an already-on-disk pet before consulting the manifest, it can be + adopted (``pet.select`` / ``/pet ``) without a manifest entry. + """ + slug = slugify(slug) + directory = pets_dir() / slug + directory.mkdir(parents=True, exist_ok=True) + sprite_path = directory / "spritesheet.webp" + try: + _write_spritesheet(spritesheet, sprite_path) + except Exception as exc: # noqa: BLE001 - normalize to one error type + raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc + + meta = { + "id": slug, + "displayName": display_name or slug, + "description": description or "", + "spritesheetPath": sprite_path.name, + "createdBy": "generator", + } + (directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") + + pet = load_pet(slug) + if pet is None or not pet.exists: + raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet") + return pet + + +def export_pet(slug: str) -> tuple[str, bytes]: + """Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes). + + Dotfiles (cached thumbs, backups) are skipped so the archive is a clean, + re-importable pet package. Raises :class:`PetStoreError` if not installed. + """ + import io + import zipfile + + root = pets_dir() + directory = root / slug.strip() + # Guard against traversal: the target must be a direct child of pets_dir. + if directory.resolve().parent != root.resolve() or not directory.is_dir(): + raise PetStoreError(f"pet '{slug}' is not installed") + + name = directory.name + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + for path in sorted(directory.iterdir()): + if path.is_file() and not path.name.startswith("."): + archive.write(path, f"{name}/{path.name}") + return f"{name}.zip", buf.getvalue() + + +_THUMB_FRAME_W = 192 +_THUMB_FRAME_H = 208 +_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI + + +def _thumbs_dir() -> Path: + path = pets_dir() / ".thumbs" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _is_petdex_host(url: str) -> bool: + """True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF).""" + from urllib.parse import urlparse + + try: + host = (urlparse(url).hostname or "").lower() + except ValueError: + return False + return host == "petdex.dev" or host.endswith(".petdex.dev") + + +def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None: + """Return a small idle-frame PNG for *slug*, cached on disk. + + Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples + it to a thumbnail. Source preference: an installed spritesheet on disk, else + *source_url* — but only when it points at petdex (so the gateway never + fetches an arbitrary client-supplied URL). Returns ``None`` when there's no + usable source or Pillow/network fails; callers render a placeholder. + + Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that + break a direct ```` and lets the result ride the authenticated + gateway as a same-origin data URL. + """ + slug = slug.strip() + if not slug: + return None + + cache = _thumbs_dir() / f"{slug}.png" + if cache.is_file(): + try: + return cache.read_bytes() + except OSError: + pass + + sheet_bytes: bytes | None = None + pet = load_pet(slug) + if pet and pet.exists: + try: + sheet_bytes = pet.spritesheet.read_bytes() + except OSError: + sheet_bytes = None + + if sheet_bytes is None and source_url and _is_petdex_host(source_url): + try: + import httpx + + resp = httpx.get( + source_url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + sheet_bytes = resp.content + except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder + logger.debug("thumb fetch failed for %s: %s", slug, exc) + + if not sheet_bytes: + return None + + try: + import io + + from PIL import Image + + with Image.open(io.BytesIO(sheet_bytes)) as im: + frame = im.convert("RGBA").crop( + (0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height)) + ) + height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W) + frame = frame.resize((_THUMB_W, height), Image.NEAREST) + buf = io.BytesIO() + frame.save(buf, format="PNG") + data = buf.getvalue() + except Exception as exc: # noqa: BLE001 + logger.debug("thumb crop failed for %s: %s", slug, exc) + return None + + try: + cache.write_bytes(data) + except OSError: + pass + return data + + +def remove_pet(slug: str) -> bool: + """Delete an installed pet directory. Returns True if anything was removed.""" + import shutil + + slug = _safe_slug(slug) + if not slug: + return False + + # The cached thumbnail lives in pets/.thumbs/.png — OUTSIDE the pet + # dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this + # slug renders this one's stale thumbnail. + try: + (_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True) + except OSError: + pass + + directory = pets_dir() / slug + if not directory.is_dir(): + return False + shutil.rmtree(directory, ignore_errors=True) + return not directory.exists() + + +def rename_pet(slug: str, display_name: str) -> str | None: + """Rename a pet's ``displayName`` AND realign its slug/dir to match. + + Generated pets are hatched under a provisional, prompt-derived slug; when + the user names the pet on the reveal screen we make that name the real + identity so lists/subtitles show what they typed, not the prompt. The dir is + renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it) + whenever that yields a free, different slug — otherwise the slug is left as + is. Returns the resulting slug on success, or ``None`` on failure. + """ + slug = _safe_slug(slug) + display_name = (display_name or "").strip() + if not slug or not display_name: + return None + directory = pets_dir() / slug + pet_json = directory / "pet.json" + if not pet_json.is_file(): + return None + try: + meta = json.loads(pet_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + meta = {} + if not isinstance(meta, dict): + meta = {} + meta["displayName"] = display_name + + new_slug = slug + desired = slugify(display_name) + if desired and desired != slug and not (pets_dir() / desired).exists(): + try: + directory.rename(pets_dir() / desired) + try: + (_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png") + except OSError: + pass + directory = pets_dir() / desired + pet_json = directory / "pet.json" + new_slug = desired + meta["id"] = new_slug + except OSError: + new_slug = slug # keep the provisional slug if the move fails + + try: + pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8") + except OSError: + return None + return new_slug + + +def _download(url: str, dest: Path, *, timeout: float) -> None: + import httpx + + try: + with httpx.stream( + "GET", + url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) as resp: + resp.raise_for_status() + tmp = dest.with_suffix(dest.suffix + ".part") + with tmp.open("wb") as fh: + for chunk in resp.iter_bytes(): + fh.write(chunk) + tmp.replace(dest) + except Exception as exc: # noqa: BLE001 + raise PetStoreError(f"download failed for {url}: {exc}") from exc + + +def _download_json(url: str, *, timeout: float) -> dict: + import httpx + + resp = httpx.get( + url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {} diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index a731dbd1f0f..1a87e66cde4 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -243,7 +243,10 @@ KANBAN_GUIDANCE = ( "- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind " "with no `.git`, `git worktree add " "${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then " - "cd there.\n" + "cd there. For a project-linked task the workspace is a fresh " + "`/.worktrees/` and `$HERMES_KANBAN_BRANCH` a deterministic " + "`/` — the main repo is two levels up, so run " + "`git worktree add` from there.\n" "- **Deliverables.** Files a human wants go in " "`kanban_complete(artifacts=[])` (top-level param; paths in " "`metadata` are NOT uploaded). Files must exist at completion.\n" @@ -709,7 +712,24 @@ PLATFORM_HINTS = { "(those are only intercepted on messaging platforms like Telegram, " "Discord, Slack, etc.; on the CLI they render as literal text). " "When referring to a file you created or changed, just state its " - "absolute path in plain text; the user can open it from there." + "absolute path in plain text; the user can open it from there. " + "Cron jobs scheduled from this session are LOCAL-ONLY: their output is " + "saved (viewable via cronjob action='list') but is NOT delivered back " + "into this terminal — there is no live-delivery channel here. If the " + "user wants to be notified when a job runs, the job's `deliver` must " + "target a gateway-connected messaging platform (e.g. deliver='telegram' " + "or 'all'). Do not promise the user that a deliver='origin' or " + "default-deliver cron job will message them in this session." + ), + "tui": ( + "You are running in the Hermes terminal UI (TUI). " + "Cron jobs scheduled from this session are LOCAL-ONLY: their output is " + "saved (viewable via cronjob action='list') but is NOT delivered back " + "into this TUI session — there is no live-delivery channel here. If the " + "user wants to be notified when a job runs, the job's `deliver` must " + "target a gateway-connected messaging platform (e.g. deliver='telegram' " + "or 'all'). Do not promise the user that a deliver='origin' or " + "default-deliver cron job will message them in this session." ), "sms": ( "You are communicating via SMS. Keep responses concise and use plain text " diff --git a/agent/retry_utils.py b/agent/retry_utils.py index 71d6963f7b4..2922156847b 100644 --- a/agent/retry_utils.py +++ b/agent/retry_utils.py @@ -8,6 +8,7 @@ rate-limited provider concurrently. import random import threading import time +from typing import Any # Monotonic counter for jitter seed uniqueness within the same process. # Protected by a lock to avoid race conditions in concurrent retry paths @@ -15,6 +16,14 @@ import time _jitter_counter = 0 _jitter_lock = threading.Lock() +# Z.AI Coding Plan's GLM-5.2 endpoint often returns HTTP 429 code 1305 +# ("The service may be temporarily overloaded...") for otherwise valid +# Hermes requests. Short retries tend to hammer the same overloaded window; +# after a few normal retries, progressively widen the wait window. Keep the +# cap interactive-friendly: a simple TUI message should fail visibly in minutes, +# not sit silent for 20+ minutes. +_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0) + def jittered_backoff( attempt: int, @@ -55,3 +64,66 @@ def jittered_backoff( jitter = rng.uniform(0, jitter_ratio * delay) return delay + jitter + + +def _error_text(error: Any) -> str: + """Best-effort flattened provider error text for retry classification.""" + parts = [ + error, + getattr(error, "message", None), + getattr(error, "body", None), + getattr(error, "response", None), + ] + return " ".join(str(part) for part in parts if part is not None).lower() + + +def is_zai_coding_overload_error(*, base_url: str | None, model: str | None, error: Any) -> bool: + """Return True for Z.AI Coding Plan transient overload 429s. + + The coding-plan endpoint reports overload as HTTP 429 with body code 1305 + and message "The service may be temporarily overloaded...". Treat only + that narrow shape specially so ordinary quota/billing 429s still fail fast + through the existing classifier. + """ + base = (base_url or "").lower() + model_name = (model or "").lower() + status = getattr(error, "status_code", None) + text = _error_text(error) + return ( + status == 429 + and "api.z.ai/api/coding/paas/v4" in base + and "glm-5.2" in model_name + and ("1305" in text or "temporarily overloaded" in text) + ) + + +def adaptive_rate_limit_backoff( + attempt: int, + *, + base_url: str | None, + model: str | None, + error: Any, + default_wait: float, + short_attempts: int = 3, +) -> tuple[float, str | None]: + """Provider-aware rate-limit backoff. + + For most providers this returns ``default_wait`` unchanged. For Z.AI + Coding Plan GLM-5.2 overloads, keep the first ``short_attempts`` retries on + the normal short exponential schedule, then switch to progressively longer + waits (30s → 60s → 90s → 120s, capped) plus light jitter. + + ``attempt`` is 1-based, matching the retry loop's logged attempt number. + Returns ``(wait_seconds, reason_label)`` where ``reason_label`` is suitable + for status/log decoration when a provider-specific policy fired. + """ + if not is_zai_coding_overload_error(base_url=base_url, model=model, error=error): + return default_wait, None + if attempt <= short_attempts: + return default_wait, "zai_coding_overload_short" + + idx = min(attempt - short_attempts - 1, len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) - 1) + base_delay = _ZAI_CODING_OVERLOAD_LONG_BACKOFF[idx] + # A smaller jitter ratio keeps long waits readable while still avoiding + # synchronized retry storms across concurrent Hermes sessions. + return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long" diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index a0f3bfc2683..2cdcff7d714 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -11,7 +11,8 @@ Pure module-level utilities extracted from ``run_agent.py``: ``_append_subdir_hint_to_multimodal`` — envelope helpers for the ``{"_multimodal": True, "content": [...], "text_summary": ...}`` dict shape returned by tools like ``computer_use``. -* ``_extract_file_mutation_targets`` / ``_extract_error_preview`` — +* ``_extract_file_mutation_targets`` / ``_extract_landed_file_mutation_paths`` / + ``_extract_error_preview`` — per-turn file-mutation verifier inputs. * ``_trajectory_normalize_msg`` — strip image blobs from a message for trajectory saving. @@ -269,6 +270,35 @@ def _extract_file_mutation_targets(tool_name: str, args: Dict[str, Any]) -> List return [] +def _extract_landed_file_mutation_paths( + tool_name: str, + args: Dict[str, Any], + result: Any, +) -> List[str]: + """Return the concrete file paths a successful mutation reports.""" + targets = _extract_file_mutation_targets(tool_name, args) + if tool_name not in _FILE_MUTATING_TOOLS or not isinstance(result, str): + return targets + try: + data = json.loads(result.strip()) + except Exception: + return targets + if not isinstance(data, dict): + return targets + + files = data.get("files_modified") + if isinstance(files, list): + landed = [str(p) for p in files if p] + if landed: + return landed + + resolved = data.get("resolved_path") + if resolved: + return [str(resolved)] + + return targets + + def _extract_error_preview(result: Any, max_len: int = 180) -> str: """Pull a one-line error summary out of a tool result for footer display.""" text = _multimodal_text_summary(result) if result is not None else "" @@ -411,6 +441,7 @@ __all__ = [ "_multimodal_text_summary", "_append_subdir_hint_to_multimodal", "_extract_file_mutation_targets", + "_extract_landed_file_mutation_paths", "_extract_error_preview", "_trajectory_normalize_msg", "make_tool_result_message", diff --git a/agent/tool_executor.py b/agent/tool_executor.py index c11453cef10..42d3c75d537 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -69,12 +69,35 @@ def _budget_for_agent(agent) -> BudgetConfig: _MAX_TOOL_WORKERS = 8 +def _flush_session_db_after_tool_progress( + agent, + messages: list, + *, + stage: str, +) -> None: + """Best-effort incremental SessionDB flush for tool-call progress. + + Tool execution can perform side effects that terminate or restart the + current Hermes process before the normal turn-end persistence path runs. + Flush the already-appended assistant/tool messages immediately so the + transcript survives destructive-but-valid tool calls. + """ + try: + agent._flush_messages_to_session_db(messages) + except Exception as exc: + logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc) + + def _ra(): """Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work.""" import run_agent return run_agent +def _is_interpreter_shutdown_submit_error(exc: RuntimeError) -> bool: + return "cannot schedule new futures after interpreter shutdown" in str(exc) + + def _emit_terminal_post_tool_call( agent, *, @@ -279,6 +302,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]", tc.id, )) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"cancelled tool result {tc.function.name}", + ) return # ── Parse args + pre-execution bookkeeping ─────────────────────── @@ -581,13 +609,40 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe if runnable_calls: max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS) with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - for i, tc, name, args in runnable_calls: + for submit_index, (i, tc, name, args) in enumerate(runnable_calls): # Propagate the agent turn's ContextVars (e.g. # _approval_session_key) AND thread-local approval/sudo # callbacks into the worker thread; clears callbacks on exit. - f = executor.submit( - propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3] - ) + try: + f = executor.submit( + propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3] + ) + except RuntimeError as submit_error: + if not _is_interpreter_shutdown_submit_error(submit_error): + raise + skipped_calls = runnable_calls[submit_index:] + logger.warning( + "interpreter shutdown while scheduling concurrent tools; " + "skipping %d unsubmitted tool(s)", + len(skipped_calls), + ) + for skipped_i, _tc, skipped_name, skipped_args in skipped_calls: + if results[skipped_i] is None: + middleware_trace = parsed_calls[skipped_i][3] + result = ( + f"Error executing tool '{skipped_name}': " + "Python interpreter is shutting down; tool was not started" + ) + results[skipped_i] = ( + skipped_name, + skipped_args, + result, + 0.0, + True, + False, + middleware_trace, + ) + break futures.append(f) # Wait for all to complete with periodic heartbeats so the @@ -768,6 +823,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # String results pass through unchanged. _tool_content = agent._tool_result_content_for_active_model(name, function_result) messages.append(make_tool_result_message(name, _tool_content, tc.id)) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {name}", + ) # ── Per-tool /steer drain ─────────────────────────────────── # Same as the sequential path: drain between each collected @@ -803,13 +863,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True) for skipped_tc in remaining_calls: skipped_name = skipped_tc.function.name - skip_msg = { - "role": "tool", - "name": skipped_name, - "content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]", - "tool_call_id": skipped_tc.id, - } - messages.append(skip_msg) + messages.append(make_tool_result_message( + skipped_name, + f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]", + skipped_tc.id, + )) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"cancelled tool result {skipped_name}", + ) break function_name = tool_call.function.name @@ -1402,6 +1465,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # (see parallel path for rationale). String results pass through. _tool_content = agent._tool_result_content_for_active_model(function_name, function_result) messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id)) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {function_name}", + ) # ── Per-tool /steer drain ─────────────────────────────────── # Drain pending steer BETWEEN individual tool calls so the @@ -1428,6 +1496,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]", skipped_tc.id, )) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"skipped tool result {skipped_name}", + ) break if agent.tool_delay > 0 and i < len(assistant_message.tool_calls): diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 1ce449eeaa7..56374b87533 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -5,12 +5,47 @@ This transport owns format conversion and normalization — NOT client lifecycle streaming, or the _run_codex_stream() call path. """ +import hashlib +import json from typing import Any, Dict, List, Optional from agent.transports.base import ProviderTransport from agent.transports.types import NormalizedResponse, ToolCall +def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]: + """Content-address the prompt cache key from the static request prefix. + + Returns ``pck_`` of (instructions + sorted tool schemas), or + None when there is nothing static to key on. The cache key is a routing + hint only — never a correctness boundary — so two requests sharing a system + prompt and tool set intentionally resolve to the same warm prefix bucket. + + The fix this exists for: recurring cron jobs build session_id as + ``cron__``, so using session_id as the cache key made every + fire cache-cold. The static prefix (identity + tools) is identical across + fires, so hashing it gives a stable key that stays warm within the + provider's cache TTL. Sorting tools by name keeps the hash insertion-order + independent. + """ + if not instructions and not tools: + return None + tools_part = "" + if tools: + sorted_tools = sorted( + (t for t in tools if isinstance(t, dict)), + key=lambda t: str(t.get("name") or t.get("type") or ""), + ) + tools_part = json.dumps( + sorted_tools, sort_keys=True, ensure_ascii=False, separators=(",", ":") + ) + # \x00 separator so instructions ending in the tool JSON can't collide with + # a request whose instructions contain that JSON and whose tools are empty. + content = f"{instructions or ''}\x00{tools_part}" + digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:24] + return f"pck_{digest}" + + class ResponsesApiTransport(ProviderTransport): """Transport for api_mode='codex_responses'. @@ -71,7 +106,10 @@ class ResponsesApiTransport(ProviderTransport): params: instructions: str — system prompt (extracted from messages[0] if not given) reasoning_config: dict | None — {effort, enabled} - session_id: str | None — used for prompt_cache_key + xAI conv header + session_id: str | None — transcript/session id; drives the xAI + x-grok-conv-id header and the Codex cache-scope headers, and is + the fallback prompt_cache_key when there is no static prefix to + content-address max_tokens: int | None — max_output_tokens timeout: float | None — per-request timeout forwarded to the SDK request_overrides: dict | None — extra kwargs merged in @@ -212,10 +250,17 @@ class ResponsesApiTransport(ProviderTransport): kwargs["parallel_tool_calls"] = True session_id = params.get("session_id") + # prompt_cache_key is content-addressed from the static prefix + # (instructions + tools), NOT session_id — recurring cron jobs carry a + # per-fire timestamp in session_id (cron__) that made every run + # cache-cold. session_id is left untouched for transcript isolation and + # the cache-scope routing headers below. Falls back to session_id when + # there is no static content to hash. + cache_key = _content_cache_key(instructions, response_tools) or session_id # xAI Responses takes prompt_cache_key in extra_body (set further # down); GitHub Models opts out of cache-key routing entirely. - if not is_github_responses and not is_xai_responses and session_id: - kwargs["prompt_cache_key"] = session_id + if not is_github_responses and not is_xai_responses and cache_key: + kwargs["prompt_cache_key"] = cache_key if reasoning_enabled and is_xai_responses: from agent.model_metadata import grok_supports_reasoning_effort @@ -326,7 +371,7 @@ class ResponsesApiTransport(ProviderTransport): merged_extra_body: Dict[str, Any] = {} if isinstance(existing_extra_body, dict): merged_extra_body.update(existing_extra_body) - merged_extra_body.setdefault("prompt_cache_key", session_id) + merged_extra_body.setdefault("prompt_cache_key", cache_key) kwargs["extra_body"] = merged_extra_body return kwargs diff --git a/agent/turn_context.py b/agent/turn_context.py index 368b8f33c34..6efa22a68ca 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -29,7 +29,10 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional from agent.iteration_budget import IterationBudget -from agent.model_metadata import estimate_request_tokens_rough +from agent.model_metadata import ( + estimate_messages_tokens_rough, + estimate_request_tokens_rough, +) logger = logging.getLogger(__name__) @@ -57,6 +60,34 @@ def _compression_made_progress( return orig_tokens > 0 and new_tokens < orig_tokens * 0.95 +def _should_run_preflight_estimate( + messages: List[Dict[str, Any]], + protect_first_n: int, + protect_last_n: int, + threshold_tokens: int, +) -> bool: + """Cheap gate for the (expensive) full preflight token estimate. + + Returns ``True`` when either: + (a) message count exceeds the protected ranges (the historical gate), or + (b) a cheap char-based estimate already crosses the configured threshold + — the few-but-huge case from issue #27405 that the count-only gate + would silently skip (a handful of very large messages never trips + the count condition, so compression was never attempted and the + turn hit a hard context-overflow error). + + Branch (b) uses ``estimate_messages_tokens_rough`` (the shared char-based + estimator) so a single large base64 image isn't mistaken for ~250K tokens. + It intentionally undercounts vs. the full request estimate — it omits the + system prompt and tool schemas — because it is only a *hint* deciding + whether to pay for the authoritative ``estimate_request_tokens_rough``, + which (together with ``should_compress``) makes the real decision. + """ + if len(messages) > protect_first_n + protect_last_n + 1: + return True + return estimate_messages_tokens_rough(messages) >= threshold_tokens + + @dataclass class TurnContext: """Values produced by the turn prologue and consumed by the turn loop.""" @@ -111,7 +142,13 @@ def build_turn_context( # Guard stdio against OSError from broken pipes (systemd/headless/daemon). install_safe_stdio() - agent._ensure_db_session() + # NOTE: the DB session row is created later, AFTER the system prompt is + # restored/built (see _ensure_db_session() below the system-prompt block). + # Creating it here — before _cached_system_prompt is populated — inserts a + # row with system_prompt=NULL on a fresh API/gateway agent that carries + # client-managed history, which then trips the "stored system prompt is + # null; rebuilding from scratch" warning and a needless first-turn prefix + # cache miss. (Issue #45499.) # Tell auxiliary_client what the live main provider/model are for this turn. try: @@ -278,6 +315,11 @@ def build_turn_context( active_system_prompt = agent._cached_system_prompt + # Create the DB session row now that _cached_system_prompt is populated, so + # the persisted snapshot is written non-NULL on the first turn (Issue + # #45499). Idempotent: _ensure_db_session() no-ops once the row exists. + agent._ensure_db_session() + # Crash-resilience: persist the inbound user turn as soon as the session row exists. try: agent._persist_session(messages, conversation_history) @@ -289,10 +331,14 @@ def build_turn_context( ) # ── Preflight context compression ── - if ( - agent.compression_enabled - and len(messages) > agent.context_compressor.protect_first_n - + agent.context_compressor.protect_last_n + 1 + # Gate the (expensive) full token estimate behind a cheap pre-check. + # See ``_should_run_preflight_estimate`` for the OR semantics that fix + # issue #27405 (a few very large messages slipping past the count gate). + if agent.compression_enabled and _should_run_preflight_estimate( + messages, + agent.context_compressor.protect_first_n, + agent.context_compressor.protect_last_n, + agent.context_compressor.threshold_tokens, ): _preflight_tokens = estimate_request_tokens_rough( messages, @@ -392,6 +438,8 @@ def build_turn_context( # Per-turn file-mutation verifier state. agent._turn_failed_file_mutations = {} + agent._turn_file_mutation_paths = set() + agent._verification_stop_nudges = 0 # Record the execution thread so interrupt()/clear_interrupt() can scope # the tool-level interrupt signal to THIS agent's thread only. diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 3a013503110..c51c18f93cc 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -166,6 +166,25 @@ def finalize_turn( # same empty-response loop again. try: agent._drop_trailing_empty_response_scaffolding(messages) + + # When the turn was interrupted and the last message is a tool + # result, append a synthetic assistant message to close the + # tool-call sequence. Without this, the session persists a + # ``tool → user`` alternation that strict providers (Gemini, + # Claude) reject, causing them to hallucinate a continuation of + # the user's message on the next turn (#48879). + # + # ``_drop_trailing_empty_response_scaffolding`` only rewinds the + # tool tail when an empty-response scaffolding flag is present; a + # clean ``/stop`` interrupt after a successful tool sets no such + # flag, so the tool result survives as the tail and we close it + # here instead. On an interrupt ``final_response`` is typically + # empty, so fall back to an explicit placeholder rather than + # persisting an empty-content assistant turn. + if interrupted: + from agent.message_sanitization import close_interrupted_tool_sequence + close_interrupted_tool_sequence(messages, final_response) + agent._persist_session(messages, conversation_history) except Exception as _persist_err: _cleanup_errors.append(f"persist_session: {_persist_err}") diff --git a/agent/verification_evidence.py b/agent/verification_evidence.py new file mode 100644 index 00000000000..9849cdd73a9 --- /dev/null +++ b/agent/verification_evidence.py @@ -0,0 +1,618 @@ +"""Coding verification evidence ledger. + +This module records what the agent actually proved while working in a code +workspace. It is deliberately passive: it never decides to run a suite, never +blocks completion, and never upgrades targeted checks into "repo green". +""" + +from __future__ import annotations + +import json +import re +import shlex +import sqlite3 +import tempfile +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional + +from hermes_constants import get_hermes_home + + +_DB_LOCK = threading.Lock() +_MAX_OUTPUT_SUMMARY_CHARS = 2000 +_MAX_EVIDENCE_AGE_DAYS = 30 +_MAX_EVENTS_PER_SESSION_ROOT = 100 +_MAX_TOTAL_UNREFERENCED_EVENTS = 10_000 +_AD_HOC_SCRIPT_NAME_PREFIXES = ("hermes-verify-", "hermes-ad-hoc-") +_VERIFY_SCHEMA_VERSION = 1 +_SHELL_SPLIT_RE = re.compile(r"\s*(?:&&|\|\||;)\s*") + + +@dataclass(frozen=True) +class VerificationEvidence: + """A classified command result worth recording.""" + + command: str + canonical_command: str + kind: str + scope: str + status: str + exit_code: int + cwd: str + root: str + session_id: str + output_summary: str = "" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _retention_cutoff() -> str: + return (datetime.now(timezone.utc) - timedelta(days=_MAX_EVIDENCE_AGE_DAYS)).isoformat() + + +def _db_path() -> Path: + return get_hermes_home() / "verification_evidence.db" + + +def _connect() -> sqlite3.Connection: + path = _db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + conn.row_factory = sqlite3.Row + _ensure_schema(conn) + return conn + + +def _ensure_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS verification_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + session_id TEXT NOT NULL, + cwd TEXT NOT NULL, + root TEXT NOT NULL, + command TEXT NOT NULL, + canonical_command TEXT NOT NULL, + kind TEXT NOT NULL, + scope TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER NOT NULL, + output_summary TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS verification_state ( + session_id TEXT NOT NULL, + root TEXT NOT NULL, + last_event_id INTEGER, + last_edit_at TEXT, + changed_paths_json TEXT NOT NULL DEFAULT '[]', + PRIMARY KEY (session_id, root) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_verification_events_session_root + ON verification_events(session_id, root, id DESC) + """ + ) + conn.execute( + "INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)", + (str(_VERIFY_SCHEMA_VERSION),), + ) + conn.commit() + + +def _split_segment_tokens(command: str) -> list[list[str]]: + segments: list[list[str]] = [] + for segment in _SHELL_SPLIT_RE.split(command.strip()): + if not segment: + continue + try: + tokens = shlex.split(segment) + except ValueError: + continue + if tokens: + segments.append(tokens) + return segments + + +def _clean_token(token: str) -> str: + token = token.strip() + while token.startswith("./"): + token = token[2:] + return token + + +def _canonical_tokens(canonical: str) -> list[str]: + try: + return [_clean_token(t) for t in shlex.split(canonical) if t] + except ValueError: + return [] + + +def _find_subsequence(tokens: list[str], needle: list[str]) -> Optional[int]: + if not tokens or not needle or len(needle) > len(tokens): + return None + cleaned = [_clean_token(t) for t in tokens] + for idx in range(0, len(cleaned) - len(needle) + 1): + if cleaned[idx:idx + len(needle)] == needle: + return idx + return None + + +def _strip_command_prefix(tokens: list[str]) -> list[str]: + """Remove harmless command prefixes before matching canonical commands.""" + remaining = list(tokens) + if remaining and remaining[0] == "env": + remaining = remaining[1:] + while remaining and "=" in remaining[0] and not remaining[0].startswith("-"): + remaining = remaining[1:] + while remaining and remaining[0] in {"command", "time", "noglob"}: + remaining = remaining[1:] + return remaining + + +def _equivalent_needles(needle: list[str]) -> list[list[str]]: + """Return command spellings equivalent to the detected canonical command.""" + candidates = [needle] + if len(needle) >= 3 and needle[1] == "run": + package_manager = needle[0] + script_name = needle[2] + if package_manager in {"npm", "pnpm", "yarn", "bun"}: + candidates.append([package_manager, script_name]) + if len(needle) == 1 and "/" in needle[0]: + candidates.extend([["bash", needle[0]], ["sh", needle[0]]]) + if needle == ["pytest"]: + candidates.extend( + [ + ["python", "-m", "pytest"], + ["python3", "-m", "pytest"], + ["uv", "run", "pytest"], + ["poetry", "run", "pytest"], + ["pipenv", "run", "pytest"], + ] + ) + return candidates + + +def _find_canonical_match(command: str, canonical_commands: list[str]) -> Optional[tuple[str, list[str]]]: + """Return ``(canonical, trailing_args)`` for the first detected command.""" + + segments = _split_segment_tokens(command) + for canonical in canonical_commands: + needle = _canonical_tokens(canonical) + if not needle: + continue + for tokens in segments: + candidate_tokens = _strip_command_prefix(tokens) + for candidate in _equivalent_needles(needle): + if candidate_tokens[:len(candidate)] == candidate: + return canonical, candidate_tokens[len(candidate):] + return None + + +def _kind_for_command(canonical: str) -> str: + lowered = canonical.lower() + if any(word in lowered for word in ("lint", "eslint", "ruff")): + return "lint" + if any(word in lowered for word in ("typecheck", "tsc", "mypy", "pyright", "ty")): + return "typecheck" + if "build" in lowered: + return "build" + if "fmt" in lowered or "format" in lowered: + return "format" + if "check" in lowered and "test" not in lowered: + return "check" + return "test" + + +def _looks_like_target(arg: str) -> bool: + if not arg or arg.startswith("-") or "=" in arg: + return False + return ( + "/" in arg + or "\\" in arg + or "::" in arg + or arg.endswith((".py", ".js", ".jsx", ".ts", ".tsx", ".rs", ".go", ".java")) + or arg.startswith(("test_", "tests", "spec", "__tests__")) + ) + + +def _scope_for_args(args: list[str]) -> str: + return "targeted" if any(_looks_like_target(arg) for arg in args) else "full" + + +def _is_under_temp_dir(token: str) -> bool: + if not token or token.startswith("-"): + return False + try: + path = Path(token).expanduser() + if not path.is_absolute(): + return False + resolved = path.resolve() + temp_root = Path(tempfile.gettempdir()).resolve() + return resolved == temp_root or temp_root in resolved.parents + except Exception: + return False + + +def _is_under_root(token: str, root: str | Path | None) -> bool: + if not root: + return False + try: + path = Path(token).expanduser().resolve() + root_path = Path(root).expanduser().resolve() + return path == root_path or root_path in path.parents + except Exception: + return False + + +def _is_temp_script_path(token: str, root: str | Path | None) -> bool: + try: + name = Path(token).expanduser().name + except Exception: + return False + return ( + name.startswith(_AD_HOC_SCRIPT_NAME_PREFIXES) + and _is_under_temp_dir(token) + and not _is_under_root(token, root) + ) + + +def _ad_hoc_script_args(tokens: list[str], root: str | Path | None) -> Optional[list[str]]: + candidate_tokens = _strip_command_prefix(tokens) + if not candidate_tokens: + return None + command = candidate_tokens[0] + if _is_temp_script_path(command, root): + return candidate_tokens[1:] + if command in {"python", "python3", "node", "bash", "sh", "ruby", "perl"}: + for idx, token in enumerate(candidate_tokens[1:], start=1): + if token == "--": + continue + if _is_temp_script_path(token, root): + return candidate_tokens[idx + 1:] + if not token.startswith("-"): + return None + return None + + +def _find_ad_hoc_match(command: str, root: str | Path | None) -> Optional[list[str]]: + for tokens in _split_segment_tokens(command): + trailing_args = _ad_hoc_script_args(tokens, root) + if trailing_args is not None: + return trailing_args + return None + + +def _summarize_output(output: str) -> str: + text = (output or "").strip() + if len(text) <= _MAX_OUTPUT_SUMMARY_CHARS: + return text + head = _MAX_OUTPUT_SUMMARY_CHARS // 3 + tail = _MAX_OUTPUT_SUMMARY_CHARS - head + return ( + text[:head] + + f"\n... [{len(text) - _MAX_OUTPUT_SUMMARY_CHARS} chars omitted] ...\n" + + text[-tail:] + ) + + +def _prune_old_events(conn: sqlite3.Connection, *, session_id: str, root: str) -> None: + """Bound ledger growth without deleting the current state pointer.""" + cutoff = _retention_cutoff() + conn.execute( + """ + DELETE FROM verification_events + WHERE session_id = ? + AND root = ? + AND id NOT IN ( + SELECT id FROM verification_events + WHERE session_id = ? AND root = ? + ORDER BY id DESC + LIMIT ? + ) + """, + (session_id, root, session_id, root, _MAX_EVENTS_PER_SESSION_ROOT), + ) + conn.execute( + """ + DELETE FROM verification_state + WHERE ( + last_edit_at IS NOT NULL + AND last_edit_at < ? + ) + OR ( + last_edit_at IS NULL + AND last_event_id IN ( + SELECT id FROM verification_events + WHERE created_at < ? + ) + ) + """, + (cutoff, cutoff), + ) + conn.execute( + """ + DELETE FROM verification_events + WHERE created_at < ? + AND id NOT IN ( + SELECT last_event_id FROM verification_state + WHERE last_event_id IS NOT NULL + ) + """, + (cutoff,), + ) + conn.execute( + """ + DELETE FROM verification_events + WHERE id NOT IN ( + SELECT id FROM verification_events + ORDER BY id DESC + LIMIT ? + ) + AND id NOT IN ( + SELECT last_event_id FROM verification_state + WHERE last_event_id IS NOT NULL + ) + """, + (_MAX_TOTAL_UNREFERENCED_EVENTS,), + ) + + +def classify_verification_command( + command: str, + *, + cwd: str | Path | None = None, + session_id: str | None = None, + exit_code: int = 0, + output: str = "", +) -> Optional[VerificationEvidence]: + """Classify a terminal command as verification evidence, if applicable.""" + + if not command or not isinstance(command, str): + return None + try: + from agent.coding_context import project_facts_for + + facts = project_facts_for(cwd) + except Exception: + facts = None + if not facts: + return None + + verify_commands = list(facts.get("verifyCommands") or []) + match = _find_canonical_match(command, verify_commands) + is_ad_hoc = False + if match is None and not verify_commands: + ad_hoc_args = _find_ad_hoc_match(command, facts.get("root")) + if ad_hoc_args is not None: + match = ("ad-hoc verification script", ad_hoc_args) + is_ad_hoc = True + if match is None: + return None + + canonical, trailing_args = match + return VerificationEvidence( + command=command, + canonical_command=canonical, + kind="ad_hoc" if is_ad_hoc else _kind_for_command(canonical), + scope="targeted" if is_ad_hoc else _scope_for_args(trailing_args), + status="passed" if int(exit_code) == 0 else "failed", + exit_code=int(exit_code), + cwd=str(Path(cwd or ".").resolve()), + root=str(facts.get("root") or Path(cwd or ".").resolve()), + session_id=str(session_id or "default"), + output_summary=_summarize_output(output), + ) + + +def record_terminal_result( + *, + command: str, + cwd: str | Path | None, + session_id: str | None, + exit_code: int, + output: str = "", +) -> Optional[dict[str, Any]]: + """Record a foreground terminal result when it is verification evidence.""" + + evidence = classify_verification_command( + command, + cwd=cwd, + session_id=session_id, + exit_code=exit_code, + output=output, + ) + if evidence is None: + return None + + created_at = _utc_now() + with _DB_LOCK: + with _connect() as conn: + cur = conn.execute( + """ + INSERT INTO verification_events( + created_at, session_id, cwd, root, command, canonical_command, + kind, scope, status, exit_code, output_summary + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + created_at, + evidence.session_id, + evidence.cwd, + evidence.root, + evidence.command, + evidence.canonical_command, + evidence.kind, + evidence.scope, + evidence.status, + evidence.exit_code, + evidence.output_summary, + ), + ) + if cur.lastrowid is None: + raise RuntimeError("verification event insert did not return an id") + event_id = int(cur.lastrowid) + conn.execute( + """ + INSERT INTO verification_state( + session_id, root, last_event_id, last_edit_at, changed_paths_json + ) VALUES (?, ?, ?, NULL, '[]') + ON CONFLICT(session_id, root) DO UPDATE SET + last_event_id = excluded.last_event_id, + last_edit_at = NULL, + changed_paths_json = '[]' + """, + (evidence.session_id, evidence.root, event_id), + ) + _prune_old_events(conn, session_id=evidence.session_id, root=evidence.root) + conn.commit() + + return {"id": event_id, **evidence.__dict__, "created_at": created_at} + + +def mark_workspace_edited( + *, + session_id: str | None, + cwd: str | Path | None, + paths: list[str] | tuple[str, ...] | None = None, +) -> Optional[dict[str, Any]]: + """Mark verification evidence stale after a successful file edit.""" + + try: + from agent.coding_context import project_facts_for + + facts = project_facts_for(cwd) + except Exception: + facts = None + if not facts: + return None + + sid = str(session_id or "default") + root = str(facts.get("root") or Path(cwd or ".").resolve()) + changed_paths = sorted({str(p) for p in (paths or []) if p}) + edited_at = _utc_now() + + with _DB_LOCK: + with _connect() as conn: + row = conn.execute( + """ + SELECT changed_paths_json FROM verification_state + WHERE session_id = ? AND root = ? + """, + (sid, root), + ).fetchone() + existing: set[str] = set() + if row is not None: + try: + existing = set(json.loads(row["changed_paths_json"] or "[]")) + except (TypeError, ValueError): + existing = set() + merged = sorted((existing | set(changed_paths)))[-200:] + conn.execute( + """ + INSERT INTO verification_state( + session_id, root, last_event_id, last_edit_at, changed_paths_json + ) VALUES (?, ?, NULL, ?, ?) + ON CONFLICT(session_id, root) DO UPDATE SET + last_edit_at = excluded.last_edit_at, + changed_paths_json = excluded.changed_paths_json + """, + (sid, root, edited_at, json.dumps(merged)), + ) + conn.commit() + + return {"session_id": sid, "root": root, "last_edit_at": edited_at, "changed_paths": changed_paths} + + +def verification_status( + *, + session_id: str | None, + cwd: str | Path | None, +) -> dict[str, Any]: + """Return the best known verification state for a session/workspace.""" + + try: + from agent.coding_context import project_facts_for + + facts = project_facts_for(cwd) + except Exception: + facts = None + if not facts: + return {"status": "not_applicable", "evidence": None} + + sid = str(session_id or "default") + root = str(facts.get("root") or Path(cwd or ".").resolve()) + with _DB_LOCK: + with _connect() as conn: + state = conn.execute( + """ + SELECT last_event_id, last_edit_at, changed_paths_json + FROM verification_state + WHERE session_id = ? AND root = ? + """, + (sid, root), + ).fetchone() + if state is None: + return { + "status": "unverified", + "evidence": None, + "root": root, + "session_id": sid, + "changed_paths": [], + } + event = None + if state["last_event_id"] is not None: + event = conn.execute( + "SELECT * FROM verification_events WHERE id = ?", + (state["last_event_id"],), + ).fetchone() + + changed_paths: list[str] = [] + try: + changed_paths = json.loads(state["changed_paths_json"] or "[]") + except (TypeError, ValueError): + changed_paths = [] + + if event is None: + return { + "status": "unverified", + "evidence": None, + "root": root, + "session_id": sid, + "changed_paths": changed_paths, + } + + evidence = dict(event) + if state["last_edit_at"] and state["last_edit_at"] > evidence["created_at"]: + status = "stale" + else: + status = evidence["status"] + return { + "status": status, + "evidence": evidence, + "root": root, + "session_id": sid, + "changed_paths": changed_paths, + } diff --git a/agent/verification_stop.py b/agent/verification_stop.py new file mode 100644 index 00000000000..e19cb22bc4e --- /dev/null +++ b/agent/verification_stop.py @@ -0,0 +1,164 @@ +"""Turn-end verification guard for coding edits. + +This module is intentionally policy-only. It never runs checks itself; it turns +the passive verification ledger into a bounded follow-up when the model tries to +finish immediately after editing code without fresh evidence. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Any, Iterable + + +_MAX_CHANGED_PATHS_IN_NUDGE = 8 + + +def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool: + """Return whether edit -> verify-before-finish behavior is enabled.""" + env = os.environ.get("HERMES_VERIFY_ON_STOP") + if env is not None: + return env.strip().lower() not in {"0", "false", "no", "off"} + if config is None: + try: + from hermes_cli.config import load_config + + config = load_config() + except Exception: + config = {} + agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None + if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg: + return bool(agent_cfg.get("verify_on_stop")) + return True + + +def _candidate_cwds(paths: Iterable[str]) -> list[Path]: + candidates: list[Path] = [] + seen: set[str] = set() + for raw in paths: + if not raw: + continue + try: + path = Path(raw).expanduser() + candidate = path if path.is_dir() else path.parent + resolved = str(candidate.resolve()) + except Exception: + continue + if resolved not in seen: + seen.add(resolved) + candidates.append(Path(resolved)) + return candidates + + +def _verification_snapshot( + *, + session_id: str | None, + changed_paths: list[str], +) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Return ``(status, facts)`` for the first edited workspace needing proof.""" + try: + from agent.coding_context import project_facts_for + from agent.verification_evidence import verification_status + except Exception: + return None + + first_snapshot: tuple[dict[str, Any], dict[str, Any]] | None = None + for cwd in _candidate_cwds(changed_paths): + facts = project_facts_for(cwd) + if not facts: + continue + status = verification_status(session_id=session_id, cwd=cwd) + snapshot = (status, facts) + if first_snapshot is None: + first_snapshot = snapshot + if str(status.get("status") or "unverified") != "passed": + return snapshot + return first_snapshot + + +def _format_changed_paths(paths: list[str]) -> str: + shown = paths[:_MAX_CHANGED_PATHS_IN_NUDGE] + lines = [f"- `{path}`" for path in shown] + remaining = len(paths) - len(shown) + if remaining > 0: + lines.append(f"- ... and {remaining} more") + return "\n".join(lines) + + +def _status_detail(status: dict[str, Any]) -> str: + state = str(status.get("status") or "unverified") + evidence = status.get("evidence") if isinstance(status.get("evidence"), dict) else None + if not evidence: + return state + + command = evidence.get("canonical_command") or evidence.get("command") + summary = str(evidence.get("output_summary") or "").strip() + parts = [state] + if command: + parts.append(f"last command `{command}`") + if summary: + max_summary = 1200 + if len(summary) > max_summary: + summary = summary[:max_summary].rstrip() + "\n... [truncated]" + parts.append(f"last output:\n{summary}") + return "\n".join(parts) + + +def build_verify_on_stop_nudge( + *, + session_id: str | None, + changed_paths: Iterable[str], + attempts: int = 0, + max_attempts: int = 2, +) -> str | None: + """Return a synthetic follow-up when edited code lacks fresh verification.""" + paths = sorted({str(p) for p in changed_paths if p}) + if not paths or attempts >= max_attempts: + return None + + snapshot = _verification_snapshot(session_id=session_id, changed_paths=paths) + if snapshot is None: + return None + status, facts = snapshot + + verify_commands = [ + str(cmd).strip() + for cmd in (facts.get("verifyCommands") or []) + if str(cmd).strip() + ] + + state = str(status.get("status") or "unverified") + if state == "passed": + return None + + if verify_commands: + command_instruction = ( + "Run the relevant verification command now (" + + ", ".join(f"`{cmd}`" for cmd in verify_commands[:3]) + + (", ..." if len(verify_commands) > 3 else "") + + "), read any failure, repair the code, and summarize what passed." + ) + else: + temp_dir = tempfile.gettempdir() + command_instruction = ( + "No canonical test/lint/build command was detected. Create a focused " + f"temporary verification script under `{temp_dir}` using an OS-safe " + "`tempfile` path with a `hermes-verify-` filename prefix, run it " + "against the changed behavior, clean it up when possible, and " + "summarize it explicitly as ad-hoc verification rather than suite " + "green." + ) + + return ( + "[System: You edited code in this turn, but the workspace does not have " + "fresh passing verification evidence yet.\n\n" + f"Verification status: {_status_detail(status)}\n\n" + f"Changed paths:\n{_format_changed_paths(paths)}\n\n" + f"{command_instruction} If verification is not possible, explain the " + "concrete blocker instead of claiming the work is fully verified.]" + ) + + +__all__ = ["build_verify_on_stop_nudge", "verify_on_stop_enabled"] diff --git a/apps/desktop/components.json b/apps/desktop/components.json index 3ad19817cdd..545360ae7a2 100644 --- a/apps/desktop/components.json +++ b/apps/desktop/components.json @@ -17,5 +17,5 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "iconLibrary": "tabler" } diff --git a/apps/desktop/electron/backend-ready.cjs b/apps/desktop/electron/backend-ready.cjs index a4899e8657a..68556f6bcbc 100644 --- a/apps/desktop/electron/backend-ready.cjs +++ b/apps/desktop/electron/backend-ready.cjs @@ -1,3 +1,5 @@ +const fs = require('node:fs') + const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m // The announcement clock starts the instant the backend process is spawned — @@ -94,8 +96,75 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) }) } +function readDashboardReadyFile(readyFile) { + if (!readyFile) return null + try { + const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8')) + const port = Number(parsed?.port) + return Number.isInteger(port) && port > 0 ? port : null + } catch { + return null + } +} + +function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) { + return new Promise((resolve, reject) => { + let done = false + let interval = null + + function cleanup() { + if (done) return + done = true + clearTimeout(timer) + if (interval) clearInterval(interval) + child.off('exit', onExit) + child.off('error', onError) + } + + function check() { + const port = readDashboardReadyFile(readyFile) + if (port) { + cleanup() + resolve(port) + } + } + + function onExit(code, signal) { + cleanup() + reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`)) + } + + function onError(err) { + cleanup() + reject(err) + } + + const timer = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`)) + }, timeoutMs) + + child.on('exit', onExit) + child.on('error', onError) + interval = setInterval(check, 50) + if (typeof interval.unref === 'function') interval.unref() + check() + }) +} + +function waitForDashboardPortAnnouncement(child, options = {}) { + const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs() + if (options.readyFile) { + return waitForDashboardReadyFile(options.readyFile, child, timeoutMs) + } + return waitForDashboardPort(child, timeoutMs) +} + module.exports = { waitForDashboardPort, + waitForDashboardPortAnnouncement, + waitForDashboardReadyFile, + readDashboardReadyFile, resolvePortAnnounceTimeoutMs, DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, MIN_PORT_ANNOUNCE_TIMEOUT_MS, diff --git a/apps/desktop/electron/backend-ready.test.cjs b/apps/desktop/electron/backend-ready.test.cjs index 8f6267b7929..2252888096c 100644 --- a/apps/desktop/electron/backend-ready.test.cjs +++ b/apps/desktop/electron/backend-ready.test.cjs @@ -14,9 +14,15 @@ const test = require('node:test') const assert = require('node:assert/strict') const { EventEmitter } = require('node:events') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') const { + readDashboardReadyFile, waitForDashboardPort, + waitForDashboardPortAnnouncement, + waitForDashboardReadyFile, resolvePortAnnounceTimeoutMs, DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, MIN_PORT_ANNOUNCE_TIMEOUT_MS, @@ -119,3 +125,75 @@ test('a late announcement after timeout does not throw (listeners torn down)', a child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n') }) }) + +// --------------------------------------------------------------------------- +// ready-file port announcement +// --------------------------------------------------------------------------- + +function mkTmpReadyFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-')) + return { + dir, + file: path.join(dir, 'ready.json'), + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) + } +} + +test('readDashboardReadyFile returns a valid port from JSON', () => { + const tmp = mkTmpReadyFile() + try { + fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 })) + assert.equal(readDashboardReadyFile(tmp.file), 4567) + } finally { + tmp.cleanup() + } +}) + +test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => { + const tmp = mkTmpReadyFile() + try { + assert.equal(readDashboardReadyFile(tmp.file), null) + fs.writeFileSync(tmp.file, '{') + assert.equal(readDashboardReadyFile(tmp.file), null) + fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 })) + assert.equal(readDashboardReadyFile(tmp.file), null) + } finally { + tmp.cleanup() + } +}) + +test('waitForDashboardReadyFile resolves when the ready file appears', async () => { + const tmp = mkTmpReadyFile() + const child = makeFakeChild() + try { + const p = waitForDashboardReadyFile(tmp.file, child, 1000) + setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20) + assert.equal(await p, 8765) + } finally { + tmp.cleanup() + } +}) + +test('waitForDashboardPortAnnouncement uses ready file when provided', async () => { + const tmp = mkTmpReadyFile() + const child = makeFakeChild() + try { + const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 }) + setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20) + assert.equal(await p, 9876) + } finally { + tmp.cleanup() + } +}) + +test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => { + const tmp = mkTmpReadyFile() + const child = makeFakeChild() + try { + const p = waitForDashboardReadyFile(tmp.file, child, 1000) + child.emit('exit', 1, null) + await assert.rejects(p, /exited before port announcement/) + } finally { + tmp.cleanup() + } +}) diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.cjs new file mode 100644 index 00000000000..7b56eed40c2 --- /dev/null +++ b/apps/desktop/electron/git-repo-scan.cjs @@ -0,0 +1,98 @@ +'use strict' + +// Repo-first discovery: walk bounded roots for git repos using only Node's `fs` +// — no native addon, so it just works for anyone who pulls main (no +// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` +// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the +// first scan stays fast. Results are cached by the backend after the first run. + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const fsp = fs.promises + +// Shallow on purpose: real projects live a few levels under home +// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always +// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos +// you actually use but keep deeper still surface via session-derived discovery, +// so this only prunes noise, never repos with history. +const DEFAULT_MAX_DEPTH = 3 +const MAX_CONCURRENCY = 32 + +// Big trees that are never themselves repos and would waste the walk. Anything +// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this +// only needs the non-hidden heavyweights. +const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv']) + +async function mapLimit(items, limit, fn) { + let cursor = 0 + + async function worker() { + while (cursor < items.length) { + const index = cursor + cursor += 1 + await fn(items[index]) + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) +} + +/** + * Scan `roots` (default: the home dir) for git repositories. Returns deduped + * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). + */ +async function scanGitRepos(roots, options = {}) { + const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH + const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] + const found = new Map() + + async function walk(dir, depth) { + if (depth > maxDepth) { + return + } + + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return // unreadable / permission denied + } + + // A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git` + // FILE is a linked worktree or submodule — those belong to their parent + // repo as lanes, not as separate projects, so we don't list them (and we + // keep descending in case a real repo sits deeper). This is what kills the + // worktree/eval-repo duplicate explosion. + if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) { + const root = dir.replace(/[/\\]+$/, '') + found.set(root, path.basename(root) || root) + + return + } + + const subdirs = [] + for (const entry of entries) { + // Real directories only (skip symlinks to avoid loops), no hidden dirs, no + // known heavy trees. + if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) { + continue + } + + subdirs.push(path.join(dir, entry.name)) + } + + await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1)) + } + + await mapLimit( + searchRoots.map(root => String(root || '').trim()).filter(Boolean), + MAX_CONCURRENCY, + root => walk(root, 0) + ) + + return [...found.entries()].map(([root, label]) => ({ label, root })) +} + +module.exports = { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.cjs new file mode 100644 index 00000000000..19b4aecf92d --- /dev/null +++ b/apps/desktop/electron/git-review-ops.cjs @@ -0,0 +1,679 @@ +'use strict' + +// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` +// (a maintained wrapper around the system git binary — same git the rest of the +// app shells to, no native build) so we read structured status()/diffSummary() +// results instead of hand-parsing porcelain. Reads degrade to null/empty on a +// non-repo / remote backend; mutations reject so the renderer can toast. + +const { execFile } = require('node:child_process') +const fs = require('node:fs/promises') +const path = require('node:path') + +const simpleGit = require('simple-git') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 +const COMMIT_CONTEXT_UNTRACKED_MAX = 80 +const UNTRACKED_LINE_COUNT_CONCURRENCY = 16 +const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024 + +// GUI-launched Electron apps on macOS inherit only a minimal PATH (no +// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out +// to — aren't found. Augment PATH with the resolved gh dir + the common +// package-manager bins so gh runs the same way it does in a terminal. +function ghEnv(ghBin) { + const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter( + dir => dir && dir !== '.' + ) + + return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) } +} + +// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on +// availability/auth without a throw. gh missing/unauthed → ok:false. +function runGh(args, cwd, ghBin) { + return new Promise(resolve => { + execFile( + ghBin || 'gh', + args, + { cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') }) + ) + }) +} + +function gitFor(cwd, gitBin) { + return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false }) +} + +// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve +// to the NEW path so the row addresses the real file for diff/stage. +function resolveRenamePath(raw) { + const path = String(raw || '').trim() + + if (!path.includes(' => ')) { + return path + } + + const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/) + + if (brace) { + const [, prefix, , to, suffix] = brace + + return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/') + } + + return path.split(' => ').pop().trim() +} + +// DiffResult.files → Map (binary files carry no line +// delta). +function countsByPath(summary) { + const map = new Map() + + for (const file of summary.files) { + map.set(resolveRenamePath(file.file), { + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions + }) + } + + return map +} + +// Untracked files don't appear in diffSummary(); count insertions from disk so +// the review tree can show +N for new files (matches an all-add diff view). +// Insertions = line count: newline bytes, plus one for a final unterminated +// line. Binary (NUL byte) → 0, mirroring git numstat's "-". +async function untrackedInsertions(cwd, relPath) { + try { + const fullPath = path.join(cwd, relPath) + const stat = await fs.stat(fullPath) + + if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) { + return 0 + } + + const buf = await fs.readFile(fullPath) + + if (buf.includes(0)) { + return 0 + } + + let lines = 0 + + for (const byte of buf) { + if (byte === 10) { + lines++ + } + } + + return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines + } catch { + return 0 + } +} + +function capText(text, maxChars, label = 'truncated') { + const value = String(text || '') + + if (value.length <= maxChars) { + return value + } + + return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n` +} + +async function fillUntrackedCounts(cwd, files) { + const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0) + + for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + await Promise.all( + pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => { + file.added = await untrackedInsertions(cwd, file.path) + }) + ) + } +} + +// Resolve the base ref for "all branch changes": merge-base with the remote +// default branch (origin/HEAD), falling back to common trunk names. +async function branchBase(git) { + const candidates = [] + + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + if (head) { + candidates.push(head) + } + } catch { + // No origin/HEAD configured. + } + + candidates.push('origin/main', 'origin/master', 'main', 'master') + + for (const ref of candidates) { + try { + const base = (await git.raw(['merge-base', 'HEAD', ref])).trim() + + if (base) { + return base + } + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// Resolve the repo's default branch NAME ("main" / "master" / …), preferring +// the remote's HEAD, then common local trunk names. Null when none is found +// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the +// trunk" regardless of which branch you're currently on. +async function defaultBranchName(git) { + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + // "origin/main" → "main"; skip the bare "origin/HEAD" placeholder. + if (head && head !== 'origin/HEAD') { + return head.replace(/^origin\//, '') + } + } catch { + // No origin/HEAD configured. + } + + // Prefer a local trunk, then a remote-only one (returns the clean name either + // way) so "branch off main" works even before main is checked out locally. + for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) { + try { + await git.raw(['rev-parse', '--verify', '--quiet', ref]) + + return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '') + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// A status file's single-letter classification, preferring the staged (index) +// code over the worktree code; untracked wins (simple-git marks both '?'). +function statusLetter(file) { + if (file.index === '?' || file.working_dir === '?') { + return '?' + } + + const code = file.index && file.index !== ' ' ? file.index : file.working_dir + + return (code || 'M').toUpperCase() +} + +const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?') + +async function reviewList(repoPath, scope, baseRef, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' }) + } catch { + return { files: [], base: null } + } + + const git = gitFor(cwd, gitBin) + + try { + if (scope === 'branch' || scope === 'lastTurn') { + const base = scope === 'branch' ? await branchBase(git) : baseRef + + if (!base) { + return { files: [], base: null } + } + + const range = scope === 'branch' ? `${base}...HEAD` : base + const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ + path: resolveRenamePath(file.file), + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions, + status: 'M', + staged: false + })) + + // "Last turn" also surfaces files created since the baseline (untracked). + if (scope === 'lastTurn') { + const status = await git.status() + + for (const path of status.not_added) { + if (!files.some(f => f.path === path)) { + files.push({ path, added: 0, removed: 0, status: '?', staged: false }) + } + } + } + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base } + } + + // Default: uncommitted (staged + unstaged + untracked), one row per path. + const [status, staged, unstaged] = await Promise.all([ + git.status(), + git.diffSummary(['--cached']), + git.diffSummary([]) + ]) + const stagedCounts = countsByPath(staged) + const unstagedCounts = countsByPath(unstaged) + + const files = status.files.map(file => { + const filePath = resolveRenamePath(file.path) + const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 } + const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 } + + return { + path: filePath, + added: sc.added + uc.added, + removed: sc.removed + uc.removed, + status: statusLetter(file), + staged: isStaged(file) + } + }) + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base: null } + } catch { + return { files: [], base: null } + } +} + +async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + if (scope === 'branch') { + const base = await branchBase(git) + + return base ? safe([`${base}...HEAD`, '--', filePath]) : '' + } + + if (scope === 'lastTurn') { + return baseRef ? safe([baseRef, '--', filePath]) : '' + } + + if (staged) { + return safe(['--cached', '--', filePath]) + } + + const worktree = await safe(['--', filePath]) + + if (worktree.trim()) { + return worktree + } + + // Untracked file: no worktree diff exists, so synthesize an all-add diff via + // --no-index (exits non-zero by design when files differ, so go around + // simple-git's reject-on-nonzero with a raw execFile). + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last +// commit" view used by the file preview. Unlike reviewDiff this never synthesizes +// a full-add for a clean tracked file (so a pristine file shows no diff); it only +// all-adds a genuinely untracked file. +async function fileDiffVsHead(repoPath, filePath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const head = await git.diff(['HEAD', '--', filePath]).catch(() => '') + + if (head.trim()) { + return head + } + + // No tracked changes vs HEAD. Only synthesize an all-add diff for a file git + // doesn't know yet; a clean tracked file must return empty. + const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '') + + if (!status.trim().startsWith('??')) { + return '' + } + + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +async function reviewStage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A']) + + return { ok: true } +} + +async function reviewUnstage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD']) + + return { ok: true } +} + +// Discard changes back to the committed state. Destructive — the renderer +// confirms first. Restores tracked files and removes untracked ones. +async function reviewRevert(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' }) + const git = gitFor(cwd, gitBin) + + if (filePath) { + await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined) + await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined) + } else { + await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined) + await git.raw(['clean', '-fd']).catch(() => undefined) + } + + return { ok: true } +} + +// Resolve a ref to a commit sha (captures the turn baseline for "Last turn"). +async function reviewRevParse(repoPath, ref, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' }) + } catch { + return null + } + + try { + return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null + } catch { + return null + } +} + +// Commit the working tree. Mirrors VS Code: if nothing is staged, stage +// everything first ("commit all"), then commit. Optionally push afterward, +// setting upstream on the first push. +async function reviewCommit(repoPath, message, push, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.staged.length === 0) { + await git.raw(['add', '-A']) + } + + await git.commit(message) + + if (push) { + const fresh = await git.status() + + if (fresh.tracking) { + await git.push() + } else if (fresh.current) { + await git.raw(['push', '-u', 'origin', fresh.current]) + } + } + + return { ok: true } +} + +// Gather the context the model needs to draft a commit message: the diff of +// what *will* be committed (staged when anything is staged, else everything +// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule), +// the names of untracked files (which carry no diff), and recent commit +// subjects for style. Diff is capped so the payload stays bounded. Reads only. +async function reviewCommitContext(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' }) + } catch { + return { diff: '', recent: '' } + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + let status + try { + status = await git.status() + } catch { + return { diff: '', recent: '' } + } + + // What will land: staged changes if any, otherwise all tracked changes vs HEAD. + let diff = capText( + status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']), + COMMIT_CONTEXT_DIFF_MAX_CHARS, + 'diff truncated for commit-message generation' + ) + + // Untracked files have no diff — list them so new files aren't invisible. + const untracked = status.not_added || [] + if (untracked.length > 0) { + const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) + const omitted = untracked.length - visible.length + const note = + `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') + + diff = diff ? `${diff}${note}` : note + } + + const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '') + + return { diff: diff || '', recent: String(recent || '').trim() } +} + +async function reviewPush(repoPath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.tracking) { + await git.push() + } else if (status.current) { + await git.raw(['push', '-u', 'origin', status.current]) + } + + return { ok: true } +} + +// gh availability + auth + whether this branch already has a PR. Reads only; +// drives the PR button's enabled/label state. `ghReady` is false when gh is +// missing OR not authenticated — either way the PR action can't run. +async function reviewShipInfo(repoPath, ghBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' }) + } catch { + return { ghReady: false, pr: null } + } + + const auth = await runGh(['auth', 'status'], cwd, ghBin) + + if (!auth.ok) { + return { ghReady: false, pr: null } + } + + const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin) + + if (!view.ok) { + // gh exits non-zero when no PR exists for the branch — that's not an error. + return { ghReady: true, pr: null } + } + + try { + const pr = JSON.parse(view.stdout) + + return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null } + } catch { + return { ghReady: true, pr: null } + } +} + +// Create a PR for the current branch (pushing first so gh has a remote ref), +// letting gh fill title/body from the commits. Returns the new PR url. +async function reviewCreatePr(repoPath, gitBin, ghBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' }) + + await reviewPush(repoPath, gitBin).catch(() => undefined) + + const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin) + + if (!created.ok) { + throw new Error('gh pr create failed (is gh installed and authenticated?)') + } + + const url = created.stdout.trim().split('\n').filter(Boolean).pop() || '' + + return { url } +} + +// Compact working-tree status for the composer coding rail: branch, ahead/behind, +// per-state change counts, +/- vs HEAD, and a capped changed-file list. +async function repoStatus(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' }) + } catch { + return null + } + + // Session cwds can point at a deleted worktree for a moment (or forever in a + // stale row). simple-git throws at construction time on a missing baseDir, so + // fail soft and hide the coding rail instead of spamming IPC handler errors. + try { + const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { + return null + } + } catch { + return null + } + + let git + try { + git = gitFor(cwd, gitBin) + } catch { + return null + } + let status + + try { + status = await git.status() + } catch { + // Not a repo / git unavailable / remote backend. + return null + } + + const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ + path: file.path, + staged: isStaged(file), + unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'), + untracked: file.index === '?' || file.working_dir === '?', + conflicted: file.index === 'U' || file.working_dir === 'U' + })) + + const result = { + branch: detached ? null : status.current || null, + defaultBranch: await defaultBranchName(git), + detached, + ahead: status.ahead || 0, + behind: status.behind || 0, + staged: files.filter(f => f.staged).length, + unstaged: files.filter(f => f.unstaged).length, + untracked: status.not_added.length, + conflicted: status.conflicted.length, + changed: files.length, + added: 0, + removed: 0, + files: files.slice(0, 200) + } + + // +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0. + try { + const summary = await git.diffSummary(['HEAD']) + result.added = summary.insertions + result.removed = summary.deletions + } catch { + // No commits yet. + } + + // `git diff HEAD` ignores untracked files, so a turn that only creates new + // files (the common case — a fresh module, a demo dir) showed +0 in the rail + // while the review pane counted them. Fold untracked insertions into `added` + // so the rail matches reality. Bounded (size cap + concurrency) like the + // review tree; only the capped file slice is counted so a huge untracked tree + // can't stall the probe. + try { + const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + const batch = await Promise.all( + untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) + ) + result.added += batch.reduce((sum, n) => sum + n, 0) + } + } catch { + // Best-effort: a probe failure just leaves untracked lines uncounted. + } + + return result +} + +module.exports = { + branchBase, + fileDiffVsHead, + repoStatus, + resolveRenamePath, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.cjs new file mode 100644 index 00000000000..fdddd13df78 --- /dev/null +++ b/apps/desktop/electron/git-review-ops.test.cjs @@ -0,0 +1,22 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const { resolveRenamePath } = require('./git-review-ops.cjs') + +test('resolveRenamePath: plain path is unchanged', () => { + assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') +}) + +test('resolveRenamePath: simple rename resolves to the new path', () => { + assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts') +}) + +test('resolveRenamePath: brace rename resolves to the new path', () => { + assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts') +}) + +test('resolveRenamePath: brace rename collapsing a segment', () => { + assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts') +}) diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.cjs new file mode 100644 index 00000000000..486686e4e4a --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.cjs @@ -0,0 +1,339 @@ +'use strict' + +// Git-driven worktree operations for the desktop "Start work" flow: spin up a +// fresh worktree the lightest way (`git worktree add -b`), list real worktrees, +// and remove them. Git is the source of truth; the renderer just drives these. + +const path = require('node:path') +const fs = require('node:fs') +const { execFile } = require('node:child_process') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +function runGit(gitBin, args, cwd) { + return new Promise((resolve, reject) => { + execFile( + gitBin, + args, + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout, stderr) => { + if (err) { + err.stderr = String(stderr || '') + reject(err) + + return + } + + resolve(String(stdout || '')) + } + ) + }) +} + +// Parse `git worktree list --porcelain`. The first record is the main worktree. +function parseWorktrees(out) { + const trees = [] + let cur = null + + for (const line of out.split('\n')) { + if (line.startsWith('worktree ')) { + if (cur) { + trees.push(cur) + } + + cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false } + } else if (!cur) { + continue + } else if (line.startsWith('branch ')) { + cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '') + } else if (line === 'detached') { + cur.detached = true + } else if (line === 'bare') { + cur.bare = true + } else if (line.startsWith('locked')) { + cur.locked = true + } + } + + if (cur) { + trees.push(cur) + } + + return trees +} + +async function listWorktrees(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' }) + } catch { + return [] + } + + try { + const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved) + + return parseWorktrees(out).map((tree, index) => ({ + path: tree.path, + branch: tree.branch, + isMain: index === 0, + detached: tree.detached, + locked: tree.locked + })) + } catch { + return [] + } +} + +// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges), +// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad +// value can't reach `git` no matter the caller (the GUI also enforces live). +function sanitizeBranch(name) { + return String(name || '') + .replace(/\s+/g, '-') + .replace(/[^\w./-]/g, '') + .replace(/-{2,}/g, '-') + .replace(/\/{2,}/g, '/') + .replace(/\.{2,}/g, '.') + .replace(/^[-./]+|[-./]+$/g, '') +} + +function slugify(name) { + const slug = String(name || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/g, '') + + return slug || 'work' +} + +const TRUNK_BRANCHES = ['main', 'master'] + +async function gitLine(gitBin, args, cwd) { + try { + return (await runGit(gitBin, args, cwd)).trim() + } catch { + return '' + } +} + +async function defaultBranch(gitBin, cwd) { + const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace( + /^origin\//, + '' + ) + + if (remote) { + return remote + } + + const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd) + + if (configured) { + return configured + } + + for (const branch of TRUNK_BRANCHES) { + if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) { + return branch + } + } + + return '' +} + +// A brand-new project folder isn't a git repo — and a freshly-init'd one has no +// commit to branch from — so `git worktree add` would fail. Make the dir a repo +// with a root commit on the user's behalf so worktrees "just work". No-op for a +// repo that already has commits; never touches the user's files (the seed commit +// is `--allow-empty`), and never inits a dir that already lives inside a repo. +async function ensureGitRepo(gitBin, dir) { + let needsRoot = false + + try { + const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim() + + if (inside !== 'true') { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } else { + // Repo exists; a worktree still needs a HEAD to branch from. + try { + await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir) + } catch { + needsRoot = true + } + } + } catch { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } + + if (needsRoot) { + // Inline identity so the seed commit lands even with no global git config. + await runGit( + gitBin, + ['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'], + dir + ) + } +} + +// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the +// primary checkout even when called from a linked worktree. +async function mainRoot(gitBin, cwd) { + const list = await listWorktrees(cwd, gitBin) + const main = list.find(tree => tree.isMain) + + return main ? main.path : cwd +} + +function uniqueDir(base) { + let dir = base + let n = 1 + + while (fs.existsSync(dir)) { + n += 1 + dir = `${base}-${n}` + } + + return dir +} + +async function addExistingBranchWorktree(gitBin, root, name) { + const branch = sanitizeBranch(name) + + if (!branch) { + throw new Error('Branch name is required.') + } + + if (branch === (await defaultBranch(gitBin, root))) { + await runGit(gitBin, ['switch', branch], root) + + return { path: root, branch, repoRoot: root } + } + + const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch))) + await runGit(gitBin, ['worktree', 'add', dir, branch], root) + + return { path: dir, branch, repoRoot: root } +} + +async function addWorktree(repoPath, options, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' }) + // A new project's folder may not be a git repo yet — init it (with a root + // commit) so the worktree has something to branch from. + await ensureGitRepo(gitBin, resolved) + const root = await mainRoot(gitBin, resolved) + const opts = options || {} + + if (opts.existingBranch) { + return addExistingBranchWorktree(gitBin, root, opts.existingBranch) + } + + const slug = slugify(opts.name || `work-${Date.now().toString(36)}`) + const branch = sanitizeBranch(opts.branch) || `hermes/${slug}` + const dir = uniqueDir(path.join(root, '.worktrees', slug)) + + const args = ['worktree', 'add', '-b', branch, dir] + + if (opts.base) { + args.push(String(opts.base)) + } + + try { + await runGit(gitBin, args, root) + } catch (err) { + // Branch name may already exist — retry checking out the existing branch + // into a fresh worktree dir instead of failing the whole flow. + if (/already exists/i.test(err.stderr || '')) { + await runGit(gitBin, ['worktree', 'add', dir, branch], root) + } else { + throw err + } + } + + return { path: dir, branch, repoRoot: root } +} + +async function removeWorktree(repoPath, worktreePath, options, gitBin) { + const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' }) + const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' }) + const root = await mainRoot(gitBin, resolvedRepo) + const args = ['worktree', 'remove'] + + if (options && options.force) { + args.push('--force') + } + + args.push(resolvedTree) + await runGit(gitBin, args, root) + + return { removed: resolvedTree } +} + +// List local branches for the "convert a branch into a worktree" picker, most +// recently committed first. Each carries whether it's already checked out in a +// worktree and, when checked out, that worktree's path. Empty on a non-repo / +// remote backend where the probe can't run. +async function listBranches(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' }) + } catch { + return [] + } + + try { + const out = await runGit( + gitBin, + ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], + resolved + ) + const trees = await listWorktrees(resolved, gitBin) + const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) + const trunk = await defaultBranch(gitBin, resolved) + + return out + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(name => ({ + name, + checkedOut: pathByBranch.has(name), + isDefault: Boolean(trunk && name === trunk), + worktreePath: pathByBranch.get(name) || null + })) + } catch { + return [] + } +} + +async function switchBranch(repoPath, branch, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' }) + const target = sanitizeBranch(branch) + + if (!target) { + throw new Error('Branch name is required.') + } + + await runGit(gitBin, ['switch', target], resolved) + + return { branch: target } +} + +module.exports = { + addWorktree, + ensureGitRepo, + listBranches, + listWorktrees, + parseWorktrees, + removeWorktree, + sanitizeBranch, + switchBranch +} diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.cjs new file mode 100644 index 00000000000..b0865d4ad77 --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.test.cjs @@ -0,0 +1,214 @@ +'use strict' + +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { + addWorktree, + ensureGitRepo, + listBranches, + parseWorktrees, + sanitizeBranch, + switchBranch +} = require('./git-worktree-ops.cjs') + +test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { + assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') + assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing') + assert.equal(sanitizeBranch(' wip~^:? '), 'wip') + assert.equal(sanitizeBranch('///'), '') +}) + +test('parseWorktrees: main checkout + linked worktree', () => { + const out = [ + 'worktree /repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /repo/.worktrees/feat', + 'HEAD def456', + 'branch refs/heads/hermes/feat', + '' + ].join('\n') + + const trees = parseWorktrees(out) + + assert.equal(trees.length, 2) + assert.equal(trees[0].path, '/repo') + assert.equal(trees[0].branch, 'main') + assert.equal(trees[1].path, '/repo/.worktrees/feat') + assert.equal(trees[1].branch, 'hermes/feat') +}) + +test('parseWorktrees: detached + locked flags', () => { + const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n') + const trees = parseWorktrees(out) + + assert.equal(trees.length, 1) + assert.equal(trees[0].detached, true) + assert.equal(trees[0].locked, true) + assert.equal(trees[0].branch, null) +}) + +test('parseWorktrees: empty input', () => { + assert.deepEqual(parseWorktrees(''), []) +}) + +test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/) + + // The whole point: a worktree can now branch off the seeded root commit. + execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir }) + assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt'))) + + // Idempotent: an already-committed repo gets no extra commit. + await ensureGitRepo('git', dir) + assert.equal(git('rev-list', '--count', 'HEAD'), '1') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('switchBranch: switches a normal checkout branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + await switchBranch(dir, 'feature', 'git') + + assert.equal(git('branch', '--show-current'), 'feature') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: lists locals and flags the checked-out branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-')) + + try { + await ensureGitRepo('git', dir) + const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim() + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + const branches = await listBranches(dir, 'git') + const names = branches.map(b => b.name).sort() + + assert.deepEqual(names, [current, 'feature'].sort()) + // The repo's own checkout is flagged; the unused branch is convertible. + assert.equal(branches.find(b => b.name === current).checkedOut, true) + assert.equal(branches.find(b => b.name === current).isDefault, true) + assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir)) + assert.equal(branches.find(b => b.name === 'feature').checkedOut, false) + assert.equal(branches.find(b => b.name === 'feature').isDefault, false) + assert.equal(branches.find(b => b.name === 'feature').worktreePath, null) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: flags a free default branch as default, not checked out', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir }) + + const branches = await listBranches(dir, 'git') + const defaultBranch = branches.find(b => b.name === trunk) + + assert.equal(defaultBranch.checkedOut, false) + assert.equal(defaultBranch.isDefault, true) + assert.equal(defaultBranch.worktreePath, null) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: a branch claimed by a worktree is flagged checked out', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-')) + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + // addWorktree converts the existing "feature" branch into a worktree. + const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git') + + assert.equal(result.branch, 'feature') + assert.ok(fs.existsSync(result.path)) + + const branches = await listBranches(dir, 'git') + + assert.equal(branches.find(b => b.name === 'feature').checkedOut, true) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: empty on a non-repo path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-')) + + try { + assert.deepEqual(await listBranches(dir, 'git'), []) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: existingBranch checks the branch out without a new branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'cool/feature'], { cwd: dir }) + + const before = git('branch', '--list').split('\n').length + const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git') + + // No new branch was created — only the existing one is checked out. + assert.equal(git('branch', '--list').split('\n').length, before) + assert.equal(result.branch, 'cool/feature') + // Dir is named off the branch slug, nested under the main repo's .worktrees. + assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/) + assert.equal( + execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(), + 'cool/feature' + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir }) + + const result = await addWorktree(dir, { existingBranch: trunk }, 'git') + + assert.equal(result.branch, trunk) + assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir)) + assert.equal(git('branch', '--show-current'), trunk) + assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-worktrees.cjs b/apps/desktop/electron/git-worktrees.cjs deleted file mode 100644 index 570397b2c95..00000000000 --- a/apps/desktop/electron/git-worktrees.cjs +++ /dev/null @@ -1,174 +0,0 @@ -'use strict' - -// Resolve git-worktree relationships for a set of session cwds, reading git's -// on-disk metadata directly (no `git` spawn per path): -// -// - A normal checkout has a `.git` DIRECTORY at its root → it's the main -// worktree; its repo root IS that directory's parent. -// - A linked worktree has a `.git` FILE: `gitdir: /.git/worktrees/`. -// That admin dir's `commondir` points back at the shared `/.git`, whose -// parent is the main repo root. -// -// Grouping by repoRoot therefore clusters a repo's main checkout with all of its -// linked worktrees, regardless of how the worktree directories are named. The -// branch (read from the worktree's own HEAD) gives each worktree a meaningful -// label. - -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') - -// Walk up from `start` to the nearest ancestor that carries a `.git` entry -// (file for a linked worktree, dir for the main checkout). Capped so a stray -// path can't loop forever. -function findGitHost(start, fsImpl) { - let dir = start - - for (let i = 0; i < 64; i += 1) { - const dotgit = path.join(dir, '.git') - - try { - if (fsImpl.existsSync(dotgit)) { - return dir - } - } catch { - return null - } - - const parent = path.dirname(dir) - - if (parent === dir) { - return null - } - - dir = parent - } - - return null -} - -function readBranch(gitDir, fsImpl) { - try { - const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim() - const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/) - - if (ref) { - return ref[1] - } - - // Detached HEAD: surface a short sha so the worktree still gets a label. - return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null - } catch { - return null - } -} - -// Given the directory that owns the `.git` entry, resolve its worktree identity. -function resolveFromHost(host, fsImpl) { - const dotgit = path.join(host, '.git') - let stat - - try { - stat = fsImpl.statSync(dotgit) - } catch { - return null - } - - if (stat.isDirectory()) { - return { - repoRoot: host, - worktreeRoot: host, - isMainWorktree: true, - branch: readBranch(dotgit, fsImpl) - } - } - - // Linked worktree: `.git` is a file pointing at the admin dir. - let contents - - try { - contents = fsImpl.readFileSync(dotgit, 'utf8').trim() - } catch { - return null - } - - const match = contents.match(/^gitdir:\s*(.+)$/m) - - if (!match) { - return null - } - - const adminDir = path.resolve(host, match[1].trim()) - - // `commondir` resolves to the shared `/.git`; fall back to walking two - // levels up from `/.git/worktrees/` if it's missing. - let commonDir - - try { - const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim() - commonDir = path.resolve(adminDir, rel) - } catch { - commonDir = path.dirname(path.dirname(adminDir)) - } - - return { - repoRoot: path.dirname(commonDir), - worktreeRoot: host, - isMainWorktree: false, - branch: readBranch(adminDir, fsImpl) - } -} - -function resolveWorktree(startPath, fsImpl = fs) { - let resolved - - try { - resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' }) - } catch { - return null - } - - let start = resolved - - try { - const stat = fsImpl.statSync(resolved) - - if (!stat.isDirectory()) { - start = path.dirname(resolved) - } - } catch { - return null - } - - const host = findGitHost(start, fsImpl) - - if (!host) { - return null - } - - return resolveFromHost(host, fsImpl) -} - -// Batch entry point for the renderer: maps each requested cwd to its worktree -// info (or null when it isn't inside a git checkout / can't be read). Dedupes so -// many sessions sharing a cwd cost one lookup. -async function worktreesForIpc(cwds, options = {}) { - const fsImpl = options.fs || fs - const list = Array.isArray(cwds) ? cwds : [] - const out = {} - - for (const cwd of list) { - if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) { - continue - } - - out[cwd] = resolveWorktree(cwd, fsImpl) - } - - return out -} - -module.exports = { - resolveWorktree, - worktreesForIpc -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 510405ac366..67b31eb4d75 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -12,6 +12,7 @@ const { powerMonitor, protocol, safeStorage, + screen, session, shell, systemPreferences @@ -37,7 +38,7 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { createLinkTitleWindow } = require('./link-title-window.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') -const { waitForDashboardPort } = require('./backend-ready.cjs') +const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') @@ -54,8 +55,25 @@ const { buildRelaunchScript } = require('./update-relaunch.cjs') const { gitRootForIpc } = require('./git-root.cjs') -const { worktreesForIpc } = require('./git-worktrees.cjs') +const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') +const { + fileDiffVsHead, + repoStatus, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} = require('./git-review-ops.cjs') +const { scanGitRepos } = require('./git-repo-scan.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') +const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') const { runRebuildWithRetry } = require('./update-rebuild.cjs') const { buildPosixCleanupScript, @@ -67,6 +85,13 @@ const { uninstallArgsForMode } = require('./desktop-uninstall.cjs') const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') +const { + MIN_WIDTH: WINDOW_MIN_WIDTH, + MIN_HEIGHT: WINDOW_MIN_HEIGHT, + sanitizeWindowState, + computeWindowOptions, + debounce +} = require('./window-state.cjs') const { authModeFromStatus, buildGatewayWsUrl, @@ -320,6 +345,7 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') +const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json') // active-profile.json records which Hermes profile the desktop launches its // local backend as. When set, startHermes() passes `hermes --profile // dashboard …`, which deterministically pins HERMES_HOME (see @@ -734,6 +760,9 @@ let rendererReloadTimes = [] // instead of re-running install.ps1 in a hot loop. Cleared explicitly by // the renderer's "Reload and retry" path or by quitting the app. let bootstrapFailure = null +// Latched non-bootstrap backend spawn failure — stops getConnection() from +// respawning hermes dashboard children in a tight loop while boot is broken. +let backendStartFailure = null // Active first-launch install, so the renderer's Cancel button (and app quit) // can abort the in-flight install.sh/ps1 instead of leaving it running. let bootstrapAbortController = null @@ -1244,6 +1273,39 @@ function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } +function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { + if (!IS_WINDOWS || !command || isCommandScript(command)) return null + + const resolved = path.resolve(String(command)) + if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null + + const scriptsDir = path.dirname(resolved) + if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null + + const venvRoot = path.dirname(scriptsDir) + const python = getNoConsoleVenvPython(venvRoot) + if (!fileExists(python)) return null + + const root = path.dirname(venvRoot) + return { + label: `existing Hermes no-console Python at ${python}`, + command: python, + args: ['-m', 'hermes_cli.main', ...dashboardArgs], + bootstrap: false, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [ + ...(directoryExists(root) ? [root] : []), + ...getVenvSitePackagesEntries(venvRoot) + ], + venvRoot + }), + kind: 'python', + readyFile: true, + shell: false + } +} + function normalizeExecutablePathForCompare(commandPath) { if (!commandPath) return null @@ -1464,6 +1526,99 @@ function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } +function readVenvHome(venvRoot) { + try { + const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') + const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im) + return match ? match[1].trim() : null + } catch { + return null + } +} + +function getNoConsoleVenvPython(venvRoot) { + if (!IS_WINDOWS) return getVenvPython(venvRoot) + + // Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages + // wiring. Falling back to the base uv/python.org pythonw.exe skips the venv + // and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched. + const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe') + if (fileExists(venvPythonw)) return venvPythonw + + const baseHome = readVenvHome(venvRoot) + if (baseHome) { + const basePythonw = path.join(baseHome, 'pythonw.exe') + if (fileExists(basePythonw)) return basePythonw + } + + return venvPythonw +} + +function toNoConsolePython(pythonPath) { + if (!IS_WINDOWS || !pythonPath) return pythonPath + + const resolved = String(pythonPath) + if (/pythonw\.exe$/i.test(resolved)) return resolved + + if (/python\.exe$/i.test(resolved)) { + const pythonw = path.join(path.dirname(resolved), 'pythonw.exe') + if (fileExists(pythonw)) return pythonw + } + + return pythonPath +} + +function applyWindowsNoConsoleSpawnHints(backend) { + if (!IS_WINDOWS || !backend?.command) return backend + + const usesHermesModule = + backend.kind === 'python' || + (Array.isArray(backend.args) && + backend.args[0] === '-m' && + backend.args[1] === 'hermes_cli.main') + + if (!usesHermesModule) return backend + + backend.command = toNoConsolePython(backend.command) + if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) { + backend.readyFile = true + } + + return backend +} + +function getVenvSitePackagesEntries(venvRoot) { + const entries = [] + if (!venvRoot) return entries + + if (IS_WINDOWS) { + const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') + if (directoryExists(sitePackages)) entries.push(sitePackages) + return entries + } + + const version = (() => { + try { + const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') + const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) + return match ? match[1].trim() : null + } catch { + return null + } + })() + if (version) { + const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') + if (directoryExists(sitePackages)) entries.push(sitePackages) + } + return entries +} + +function makeDashboardReadyFile() { + const dir = path.join(app.getPath('userData'), 'backend-ready') + fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`) +} + // resolveGitBinary — locate git.exe on Windows. A fresh installer-driven // install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on // PATH), so a bare spawn('git') ENOENTs and self-update checks fail with @@ -1493,6 +1648,30 @@ function resolveGitBinary() { return _gitBinaryCache } +// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH +// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually +// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's +// terminal. Check the common install locations first, then PATH. Cached. +let _ghBinaryCache = null +function resolveGhBinary() { + if (_ghBinaryCache) return _ghBinaryCache + + const candidates = [] + + if (IS_WINDOWS) { + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { + candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) + } + } else { + const home = app.getPath('home') + candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh')) + } + + _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache +} + function recentHermesLog() { return hermesLog.slice(-20).join('\n') } @@ -1522,6 +1701,36 @@ function writeDesktopUpdateConfig(config) { writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2)) } +// ─── Main-window geometry persistence (window-state.json) ────────────────── + +function readWindowState() { + try { + return sanitizeWindowState(JSON.parse(fs.readFileSync(DESKTOP_WINDOW_STATE_PATH, 'utf8'))) + } catch { + return null + } +} + +// Persist the window's restored (non-maximized) bounds plus its maximized flag. +// getNormalBounds() keeps the pre-maximize size, so un-maximizing next session +// lands back where the user actually sized the window. +function persistWindowState() { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return + try { + const { x, y, width, height } = mainWindow.getNormalBounds() + fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true }) + writeFileAtomic( + DESKTOP_WINDOW_STATE_PATH, + JSON.stringify({ x, y, width, height, isMaximized: mainWindow.isMaximized() }, null, 2) + ) + } catch (err) { + rememberLog(`[window-state] persist failed: ${err?.message || err}`) + } +} + +// resized/moved fire many times mid-drag on Linux; debounce to one write. +const schedulePersistWindowState = debounce(persistWindowState, 250) + // Match the backend's source resolution but bias toward a real git checkout. // Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT. // HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree. @@ -1667,15 +1876,34 @@ async function checkUpdates() { } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) - const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([ + const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), - git(['rev-list', `HEAD..origin/${branch}`, '--count']), git(['status', '--porcelain']), - git(['rev-parse', '--abbrev-ref', 'HEAD']) + git(['rev-parse', '--abbrev-ref', 'HEAD']), + git(['rev-parse', '--is-shallow-repository']), + // merge-base exits non-zero with empty stdout when HEAD shares no common + // ancestor with the freshly fetched tip — exactly the shallow-clone case. + git(['merge-base', 'HEAD', `origin/${branch}`]) ]) - const behind = Number.parseInt(countStr, 10) || 0 + const isShallow = shallowStr === 'true' + const hasMergeBase = Boolean(mergeBaseStr) + // Only enumerate the commit count when it is meaningful. On a shallow checkout + // with no merge-base, `rev-list --count` walks the entire remote ancestry + // (thousands of commits, see #51922) and resolveBehindCount discards the + // result anyway in favour of a SHA compare — so skip the expensive query. + const countStr = shouldCountCommits({ isShallow, hasMergeBase }) + ? await git(['rev-list', `HEAD..origin/${branch}`, '--count']) + : '' + + const behind = resolveBehindCount({ + countStr, + currentSha, + targetSha, + isShallow, + hasMergeBase + }) const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : [] return { @@ -2531,20 +2759,25 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { const python = findPythonForRoot(root) if (!python) return null - return { + const venvRoot = path.join(root, 'venv') + const venvPython = getVenvPython(venvRoot) + const command = + IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python) + + return applyWindowsNoConsoleSpawnHints({ kind: 'python', label, - command: python, + command, args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [root], - venvRoot: path.join(root, 'venv') + venvRoot }), root, bootstrap: Boolean(options.bootstrap), shell: false - } + }) } // createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the @@ -2553,11 +2786,14 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { // ensureRuntime() to create / refresh it before launch. function createActiveBackend(dashboardArgs) { const venvPython = getVenvPython(VENV_ROOT) + const command = fileExists(venvPython) + ? getNoConsoleVenvPython(VENV_ROOT) + : toNoConsolePython(findSystemPython()) - return { + return applyWindowsNoConsoleSpawnHints({ kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, - command: fileExists(venvPython) ? venvPython : findSystemPython(), + command, args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, @@ -2567,7 +2803,7 @@ function createActiveBackend(dashboardArgs) { root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false - } + }) } function resolveHermesBackend(dashboardArgs) { @@ -2628,6 +2864,11 @@ function resolveHermesBackend(dashboardArgs) { } if (hermesCommand) { + const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) + if (unwrapped) { + return unwrapped + } + // Smoke-test the candidate before trusting it. A `hermes` shim // left behind by a half-uninstalled pip install (or a venv // entry-point pointing at a deleted interpreter) still resolves @@ -2637,7 +2878,7 @@ function resolveHermesBackend(dashboardArgs) { // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { - return { + return unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, args: dashboardArgs, @@ -2667,15 +2908,15 @@ function resolveHermesBackend(dashboardArgs) { // failure, fall through to step 6 so the bootstrap runner pulls // a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv. if (canImportHermesCli(python)) { - return { + return applyWindowsNoConsoleSpawnHints({ kind: 'python', label: `installed hermes_cli module via ${python}`, - command: python, + command: toNoConsolePython(python), args: ['-m', 'hermes_cli.main', ...dashboardArgs], bootstrap: false, env: {}, shell: false - } + }) } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -2709,7 +2950,7 @@ function resolveHermesBackend(dashboardArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) - return backend + return applyWindowsNoConsoleSpawnHints(backend) } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't @@ -2849,7 +3090,7 @@ async function ensureRuntime(backend) { ) } - backend.command = venvPython + backend.command = getNoConsoleVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', @@ -2858,10 +3099,9 @@ async function ensureRuntime(backend) { running: true, error: null }) - return backend + return applyWindowsNoConsoleSpawnHints(backend) } - function fetchJson(url, token, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) @@ -4772,6 +5012,7 @@ function resetBootProgressForReconnect() { function resetHermesConnection() { connectionPromise = null + backendStartFailure = null if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') @@ -4933,6 +5174,7 @@ async function spawnPoolBackend(profile, entry) { const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() + const readyFile = backend.readyFile ? makeDashboardReadyFile() : null rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) @@ -4953,7 +5195,8 @@ async function spawnPoolBackend(profile, entry) { // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] @@ -4986,7 +5229,10 @@ async function spawnPoolBackend(profile, entry) { }) // Discover the ephemeral port the child bound to - const port = await Promise.race([waitForDashboardPort(child), startFailed]) + const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + if (readyFile) { + fs.unlink(readyFile, () => {}) + } entry.port = port const baseUrl = `http://127.0.0.1:${port}` @@ -5099,6 +5345,9 @@ async function startHermes() { if (bootstrapFailure) { throw bootstrapFailure } + if (backendStartFailure) { + throw backendStartFailure + } if (connectionPromise) return connectionPromise connectionPromise = (async () => { @@ -5152,6 +5401,7 @@ async function startHermes() { const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() + const readyFile = backend.readyFile ? makeDashboardReadyFile() : null await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84) rememberLog(`Starting Hermes backend via ${backend.label}`) @@ -5178,7 +5428,8 @@ async function startHermes() { // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] @@ -5234,12 +5485,16 @@ async function startHermes() { await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86) // Discover the ephemeral port the child bound to - const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed]) + const port = await Promise.race([waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed]) + if (readyFile) { + fs.unlink(readyFile, () => {}) + } const baseUrl = `http://127.0.0.1:${port}` await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90) await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true + backendStartFailure = null const authToken = await adoptServedDashboardToken(baseUrl, token, { // The exit/error handlers null hermesProcess when the child dies. childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, @@ -5265,6 +5520,7 @@ async function startHermes() { } })().catch(error => { const message = error instanceof Error ? error.message : String(error) + backendStartFailure = error instanceof Error ? error : new Error(message) updateBootProgress( { error: message, @@ -5385,13 +5641,149 @@ function createNewSessionWindow() { return spawnSecondaryWindow({ newSession: true }) } +// The pet overlay: a single transparent, frameless, always-on-top window that +// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out" +// here so it can leave the app's bounds and stay visible while Hermes is +// minimized (Codex-style task-completion glance). It carries no gateway +// connection of its own — the main renderer is the single source of truth and +// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders +// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control. +let petOverlayWindow = null + +function petOverlayUrl() { + if (DEV_SERVER) { + return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/` + } + + return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/` +} + +function spawnPetOverlayWindow(bounds) { + const win = new BrowserWindow({ + width: Math.max(80, Math.round(bounds?.width || 220)), + height: Math.max(80, Math.round(bounds?.height || 220)), + x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined, + y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined, + frame: false, + transparent: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + // Windows/Linux need this so the helper window does not get its own + // taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make + // the whole app look like it vanished when the only newly-created visible + // window is a frameless overlay. Use NSPanel + Mission Control hiding below + // instead, leaving the main Hermes app as the Dock/cmd-tab anchor. + skipTaskbar: !IS_MAC, + hasShadow: false, + alwaysOnTop: true, + // macOS panels are non-activating helper windows and can float over full + // screen spaces without becoming the app's main switcher window. + type: IS_MAC ? 'panel' : undefined, + hiddenInMissionControl: IS_MAC, + // Non-activating: the overlay must never become the app's key/main window, + // or it (a frameless, taskbar-skipping panel) becomes the app's switcher + // anchor and the Hermes icon drops out of cmd/alt-tab — especially when the + // main window is minimized. We flip this on only while the composer needs + // the keyboard (see hermes:pet-overlay:set-focusable). + focusable: false, + show: false, + // Fully transparent — the renderer paints only the sprite + bubble. + backgroundColor: '#00000000', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true, + // Keep the sprite animating + bubble updating while the main window is + // minimized/blurred — the whole point of the overlay. + backgroundThrottling: false + } + }) + + // Float above other apps and follow the user across desktops so the pet is + // always reachable. `floating` + `type: panel` is the macOS NSPanel path; the + // more aggressive `screen-saver` level can interfere with normal app/window + // switching semantics. + win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') + win.setHiddenInMissionControl?.(true) + try { + // Electron docs: macOS may transform process type on each + // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, + // which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal + // ForegroundApplication class so shift-clicking the pet never drops the app + // out of app switchers. + win.setVisibleOnAllWorkspaces( + true, + IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined + ) + } catch { + // Not supported everywhere — best effort. + } + + wireCommonWindowHandlers(win) + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.showInactive() + }) + + win.on('closed', () => { + if (petOverlayWindow === win) { + petOverlayWindow = null + } + + // If the overlay went away on its own (e.g. ⌘W), tell the main renderer to + // pop the pet back in so it doesn't stay hidden. Harmless echo when we're + // the ones who closed it (popInPet already cleared the active flag). + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' }) + } + }) + + win.loadURL(petOverlayUrl()) + + return win +} + +function openPetOverlay(bounds) { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + if (bounds) { + petOverlayWindow.setBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(80, Math.round(bounds.width)), + height: Math.max(80, Math.round(bounds.height)) + }) + } + + petOverlayWindow.showInactive() + + return petOverlayWindow + } + + petOverlayWindow = spawnPetOverlayWindow(bounds) + + return petOverlayWindow +} + +function closePetOverlay() { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.close() + } + + petOverlayWindow = null +} + function createWindow() { const icon = getAppIconPath() + const savedWindowState = readWindowState() mainWindow = new BrowserWindow({ - width: 1220, - height: 800, - minWidth: 400, - minHeight: 620, + ...computeWindowOptions(savedWindowState, screen.getAllDisplays()), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, title: 'Hermes', // Frameless title bar on every platform so the renderer can paint the // "hide sidebar" button (and other left-side titlebar tools) flush with @@ -5433,6 +5825,8 @@ function createWindow() { } } + if (savedWindowState?.isMaximized) mainWindow.maximize() + mainWindow.once('ready-to-show', () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() }) @@ -5442,6 +5836,19 @@ function createWindow() { mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + // Reopen where the user left off. resized/moved settle once per drag; close is + // the cross-platform backstop, flushed synchronously before the window is gone. + mainWindow.on('resized', schedulePersistWindowState) + mainWindow.on('moved', schedulePersistWindowState) + mainWindow.on('maximize', schedulePersistWindowState) + mainWindow.on('unmaximize', schedulePersistWindowState) + mainWindow.on('close', () => schedulePersistWindowState.flush()) + + // The overlay rides the main window — closing the app's primary window must + // tear it down too (otherwise it strands as an orphan that blocks + // window-all-closed from quitting on Windows/Linux). + mainWindow.on('closed', () => closePetOverlay()) + wireCommonWindowHandlers(mainWindow) mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -5562,6 +5969,116 @@ ipcMain.handle('hermes:window:openNewSession', async () => { return { ok: true } }) + +// --- Pet overlay (pop-out mascot) ----------------------------------------- +// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space +// bounds (screen=false): convert to screen space by adding the main window's +// content origin so the pet lands where it sat in-window. A remembered/dragged +// spot passes screen-space bounds (screen=true) and is used as-is. We return the +// resolved screen bounds so the renderer can persist exactly where it opened. +ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } +}) +ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + return { ok: true } +}) +// Drag: the overlay reports a new absolute screen position (it already knows the +// pointer's screen coords), we just move the window. +ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { + return + } + + petOverlayWindow.setBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(80, Math.round(bounds.width)), + height: Math.max(80, Math.round(bounds.height)) + }) +}) +// Click-through: the overlay window is a full rectangle but only the pet pixels +// should be interactive. The renderer toggles this as the cursor enters/leaves +// the sprite so transparent margins pass clicks to whatever is behind. +ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } +}) +// The overlay is a non-activating panel (focusable:false) so it never steals +// the app's cmd/alt-tab anchor from the main window. But the pop-up composer +// needs the keyboard, so the renderer asks us to flip it focusable + focus it +// while the composer is open, then back to non-activating when it closes. +ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { + petOverlayWindow.focus() + } +}) +// Main renderer → overlay: forward the latest pet state for the overlay to render. +ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } +}) +// Overlay → main renderer: control messages (pop back in, composer submit). +ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) +}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -5569,6 +6086,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { rememberLog('[bootstrap] reset requested by renderer; clearing latched failure') await teardownPrimaryBackendAndWait() bootstrapFailure = null + backendStartFailure = null bootstrapState = { active: false, manifest: null, @@ -5595,6 +6113,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) } bootstrapFailure = null + backendStartFailure = null resetHermesConnection() return { ok: true } }) @@ -6276,7 +6795,164 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) -ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds)) +// Reveal a path in the OS file manager (Finder / Explorer / Files). +ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } +}) + +// Rename a file/folder in place. The renderer passes the existing path + a new +// base name; the destination is resolved in the SAME parent dir so a rename can +// never move the item elsewhere or traverse out. Rejects on a name collision. +ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } +}) + +// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path +// is hardened (resolveRequestedPathForIpc) and the parent must already exist — +// this never creates directory trees or escapes the allowed roots, and content +// is size-capped so it can't be abused as a bulk-write primitive. +ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } +}) + +// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" +// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. +ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true +}) + +// Git-driven worktree management ("Start work" flow). Errors surface to the +// renderer as rejected promises so it can toast a friendly message. +ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => + listWorktrees(repoPath, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => + addWorktree(repoPath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => + removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => + switchBranch(repoPath, branch, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => + listBranches(repoPath, resolveGitBinary()) +) + +// Compact repo status (branch, ahead/behind, change counts + files) for the +// composer coding rail. Returns null on a non-repo / remote backend so the rail +// hides cleanly rather than erroring. +ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) + +// Codex-style review pane: list changed files for a scope, fetch one file's +// unified diff, and stage / unstage / revert. Reads return empty on failure; +// mutations reject so the renderer can toast. +ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => + reviewList(repoPath, scope, baseRef, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => + reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) +) +// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). +ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => + fileDiffVsHead(repoPath, filePath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => + reviewStage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => + reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => + reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => + reviewRevParse(repoPath, ref, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => + reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => + reviewCommitContext(repoPath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) +ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) +ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => + reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) +) + +// Repo-first project discovery: scan bounded roots for git repos (pure fs walk, +// no native addon). Never throws to the renderer — failures yield an empty list. +ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { + try { + return await scanGitRepos(roots || [], options || {}) + } catch { + return [] + } +}) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { @@ -6772,6 +7448,10 @@ function configureSpellChecker() { } app.on('before-quit', () => { + // The always-on-top overlay isn't a "real" app window; close it so a stray + // pet can't keep the process alive or float over a quit app. + closePetOverlay() + // Quitting mid-install should stop the installer, not orphan it. if (bootstrapAbortController) { try { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 68f75c7b81f..aa8bcc16128 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -7,6 +7,32 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'), + petOverlay: { + // Main renderer → main process: window lifecycle + drag. `request` is + // `{ bounds, screen }`; resolves with the screen bounds it actually used. + open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request), + close: () => ipcRenderer.invoke('hermes:pet-overlay:close'), + setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds), + setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore), + // Flip the overlay focusable (and focus it) while the composer needs keys. + setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable), + // Main renderer → overlay (forwarded by main): push the latest pet state. + pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload), + // Overlay → main renderer (forwarded by main): pop back in / composer submit. + control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload), + // Overlay subscribes to state pushes. + onState: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) + }, + // Main renderer subscribes to overlay control messages. + onControl: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) + } + }, getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), @@ -56,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), - worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds), + revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), + renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName), + writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content), + trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath), + git: { + worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath), + worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options), + worktreeRemove: (repoPath, worktreePath, options) => + ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), + branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), + branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), + fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), + scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), + review: { + list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef), + diff: (repoPath, filePath, scope, baseRef, staged) => + ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged), + stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath), + unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath), + revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath), + revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref), + commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push), + commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath), + push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath), + shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath), + createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath) + } + }, terminal: { dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), diff --git a/apps/desktop/electron/update-count.cjs b/apps/desktop/electron/update-count.cjs new file mode 100644 index 00000000000..de8d57c4ee6 --- /dev/null +++ b/apps/desktop/electron/update-count.cjs @@ -0,0 +1,28 @@ +'use strict' + +// Whether `git rev-list HEAD..origin/ --count` produces a meaningful +// number worth computing. On a SHALLOW checkout (installer clones with +// --depth 1) the local history often shares no merge-base with the freshly +// fetched origin tip, so the count enumerates the entire remote ancestry and +// returns a bogus huge number (e.g. 12104) — see #51922. resolveBehindCount +// discards that bogus count in favour of a SHA compare, so the caller should +// SKIP the expensive rev-list entirely in that case rather than run it and +// throw the result away. +function shouldCountCommits({ isShallow, hasMergeBase }) { + return !(isShallow && !hasMergeBase) +} + +// Resolve how many commits the local checkout is behind origin for the desktop +// update indicator. When the count isn't meaningful (shallow + no merge-base) +// fall back to a binary up-to-date check by SHA, exactly like the official-SSH +// path in checkUpdates() and the CLI guard in hermes_cli/banner.py. Full clones +// (developers / Docker dev images) keep the exact count path unchanged. +function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { + if (!shouldCountCommits({ isShallow, hasMergeBase })) { + if (currentSha && targetSha && currentSha === targetSha) return 0 + return 1 // behind by an unknown amount — show a generic "update available" + } + return Number.parseInt(countStr, 10) || 0 +} + +module.exports = { resolveBehindCount, shouldCountCommits } diff --git a/apps/desktop/electron/update-count.test.cjs b/apps/desktop/electron/update-count.test.cjs new file mode 100644 index 00000000000..69ee99aa616 --- /dev/null +++ b/apps/desktop/electron/update-count.test.cjs @@ -0,0 +1,79 @@ +'use strict' +const test = require('node:test') +const assert = require('node:assert/strict') +const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') + +// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` +// unconditionally, so a shallow checkout with no merge-base surfaced the bogus +// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch. +test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => { + assert.equal(resolveBehindCount({ + countStr: '12104', currentSha: 'aaa', targetSha: 'bbb', + isShallow: true, hasMergeBase: false, + }), 1) +}) + +test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => { + assert.equal(resolveBehindCount({ + countStr: '12104', currentSha: 'abc', targetSha: 'abc', + isShallow: true, hasMergeBase: false, + }), 0) +}) + +test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => { + assert.equal(resolveBehindCount({ + countStr: '3', currentSha: 'aaa', targetSha: 'bbb', + isShallow: true, hasMergeBase: true, + }), 3) +}) + +test('full (non-shallow) clone keeps the exact count path unchanged', () => { + assert.equal(resolveBehindCount({ + countStr: '7', currentSha: 'aaa', targetSha: 'bbb', + isShallow: false, hasMergeBase: true, + }), 7) +}) + +test('up-to-date full clone reports 0', () => { + assert.equal(resolveBehindCount({ + countStr: '0', currentSha: 'x', targetSha: 'x', + isShallow: false, hasMergeBase: true, + }), 0) +}) + +test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => { + assert.equal(resolveBehindCount({ + countStr: '', currentSha: 'aaa', targetSha: 'bbb', + isShallow: false, hasMergeBase: true, + }), 0) +}) + +// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates(). +// FAIL-BEFORE: in the shallow + no-merge-base case the caller ran rev-list +// unconditionally and discarded the bogus result; this predicate lets the +// caller SKIP the whole-ancestry enumeration in exactly that case (#51922). +test('shallow checkout with no merge-base SKIPS the rev-list count', () => { + assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: false }), false) +}) + +test('shallow checkout WITH a merge-base still runs the count', () => { + assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: true }), true) +}) + +test('full (non-shallow) clone always runs the count', () => { + assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: true }), true) + assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: false }), true) +}) + +// The skip path produces an empty countStr; resolveBehindCount must NOT trust +// it and must fall through to the SHA compare (mirrors the live call site). +test('skipped-count path resolves via SHA compare, never via empty countStr', () => { + assert.equal(resolveBehindCount({ + countStr: '', currentSha: 'aaa', targetSha: 'bbb', + isShallow: true, hasMergeBase: false, + }), 1) + assert.equal(resolveBehindCount({ + countStr: '', currentSha: 'same', targetSha: 'same', + isShallow: true, hasMergeBase: false, + }), 0) +}) diff --git a/apps/desktop/electron/window-state.cjs b/apps/desktop/electron/window-state.cjs new file mode 100644 index 00000000000..6157e469b24 --- /dev/null +++ b/apps/desktop/electron/window-state.cjs @@ -0,0 +1,117 @@ +/** + * Pure geometry helpers for window-state.json — restoring the main window's + * size, position, and maximized flag across launches. Side-effect-free so the + * part that actually matters (rejecting garbage + off-screen bounds) is + * unit-testable without booting Electron; main.cjs owns the file I/O and the + * live `screen` displays. + */ + +// Defaults mirror the historical hardcoded BrowserWindow size; MIN_* mirror its +// minWidth/minHeight so a restored size never undershoots what the live window +// allows. A fresh install (no saved state) is byte-identical to before. +const DEFAULT_WIDTH = 1220 +const DEFAULT_HEIGHT = 800 +const MIN_WIDTH = 400 +const MIN_HEIGHT = 620 + +// Keep at least this much of the window over a display work area before we trust +// a saved position, so the title bar stays grabbable after a monitor unplugs. +const MIN_VISIBLE = 48 + +const finite = v => typeof v === 'number' && Number.isFinite(v) +const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) + +// Parse raw JSON → clean state, or null if garbage. width/height are required +// and floored; x/y survive only as a finite pair; isMaximized is strict. +function sanitizeWindowState(raw) { + if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null + + const state = { + width: Math.max(MIN_WIDTH, Math.round(raw.width)), + height: Math.max(MIN_HEIGHT, Math.round(raw.height)), + isMaximized: raw.isMaximized === true + } + if (finite(raw.x) && finite(raw.y)) { + state.x = Math.round(raw.x) + state.y = Math.round(raw.y) + } + return state +} + +// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both +// axes. `displays` is Electron's screen.getAllDisplays() shape. +function onScreen(bounds, displays) { + if (!Array.isArray(displays)) return false + return displays.some(({ workArea: a } = {}) => { + if (!a) return false + const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) + const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) + return x >= MIN_VISIBLE && y >= MIN_VISIBLE + }) +} + +// Sanitized state (or null) → BrowserWindow size/position options. Always sets +// width/height, capped to the largest current display so a size saved on a +// since-disconnected bigger monitor can't exceed any screen the user now has. +// Sets x/y only when still on-screen; otherwise Electron centers the window. +function computeWindowOptions(state, displays) { + const opts = { + width: finite(state?.width) ? state.width : DEFAULT_WIDTH, + height: finite(state?.height) ? state.height : DEFAULT_HEIGHT + } + + const cap = (Array.isArray(displays) ? displays : []).reduce( + (m, { workArea: a } = {}) => + a && finite(a.width) && finite(a.height) + ? { width: Math.max(m.width, a.width), height: Math.max(m.height, a.height) } + : m, + { width: 0, height: 0 } + ) + if (cap.width && cap.height) { + opts.width = clamp(opts.width, MIN_WIDTH, cap.width) + opts.height = clamp(opts.height, MIN_HEIGHT, cap.height) + } + + if ( + state && + finite(state.x) && + finite(state.y) && + onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays) + ) { + opts.x = state.x + opts.y = state.y + } + return opts +} + +// Trailing debounce: collapse a burst of resize/move events (Linux fires many +// mid-drag) into a single run `delayMs` after the last. `.flush()` runs now and +// cancels the pending timer — used on close, before the window is gone. +function debounce(fn, delayMs) { + let timer = null + const debounced = () => { + clearTimeout(timer) + timer = setTimeout(() => { + timer = null + fn() + }, delayMs) + } + debounced.flush = () => { + clearTimeout(timer) + timer = null + fn() + } + return debounced +} + +module.exports = { + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + MIN_WIDTH, + MIN_HEIGHT, + MIN_VISIBLE, + sanitizeWindowState, + onScreen, + computeWindowOptions, + debounce +} diff --git a/apps/desktop/electron/window-state.test.cjs b/apps/desktop/electron/window-state.test.cjs new file mode 100644 index 00000000000..2f3ea6ca52a --- /dev/null +++ b/apps/desktop/electron/window-state.test.cjs @@ -0,0 +1,135 @@ +/** + * Unit tests for the pure window-state geometry helpers. These cover the logic + * that protects the user: garbage rejection, off-screen fallback, oversized + * clamping, and the debounce that collapses mid-drag write storms. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + MIN_WIDTH, + MIN_HEIGHT, + sanitizeWindowState, + onScreen, + computeWindowOptions, + debounce +} = require('./window-state.cjs') + +// A single 1920×1080 monitor (work area trimmed for the taskbar). +const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] +// A laptop panel left behind after a bigger external monitor is unplugged. +const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }] + +// ─── sanitizeWindowState ─────────────────────────────────────────────────── + +test('sanitizeWindowState rejects missing/garbage input', () => { + for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) { + assert.equal(sanitizeWindowState(bad), null) + } +}) + +test('sanitizeWindowState keeps a valid full state and rounds HiDPI fractions', () => { + assert.deepEqual(sanitizeWindowState({ x: 100.6, y: 50.2, width: 1400.4, height: 900.7, isMaximized: true }), { + x: 101, + y: 50, + width: 1400, + height: 901, + isMaximized: true + }) +}) + +test('sanitizeWindowState floors size to the minimums', () => { + const state = sanitizeWindowState({ width: 10, height: 10 }) + assert.equal(state.width, MIN_WIDTH) + assert.equal(state.height, MIN_HEIGHT) +}) + +test('sanitizeWindowState drops a partial position but keeps the size', () => { + assert.deepEqual(sanitizeWindowState({ x: 100, width: 1400, height: 900 }), { + width: 1400, + height: 900, + isMaximized: false + }) +}) + +test('sanitizeWindowState treats isMaximized strictly', () => { + assert.equal(sanitizeWindowState({ width: 1400, height: 900, isMaximized: 'yes' }).isMaximized, false) +}) + +// ─── onScreen ────────────────────────────────────────────────────────────── + +test('onScreen accepts a window on the primary or a secondary display', () => { + const dual = [...PRIMARY, { workArea: { x: 1920, y: 0, width: 2560, height: 1400 } }] + assert.equal(onScreen({ x: 100, y: 100, width: 1220, height: 800 }, PRIMARY), true) + assert.equal(onScreen({ x: 2200, y: 200, width: 1220, height: 800 }, dual), true) +}) + +test('onScreen rejects off-screen, slivers, and bad input', () => { + assert.equal(onScreen({ x: 3000, y: 100, width: 1220, height: 800 }, PRIMARY), false) // past right edge + assert.equal(onScreen({ x: 100, y: -900, width: 1220, height: 800 }, PRIMARY), false) // above top + assert.equal(onScreen({ x: 1910, y: 100, width: 1220, height: 800 }, PRIMARY), false) // ~10px sliver + assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, []), false) + assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, null), false) +}) + +// ─── computeWindowOptions ────────────────────────────────────────────────── + +test('computeWindowOptions falls back to defaults with no saved state', () => { + assert.deepEqual(computeWindowOptions(null, PRIMARY), { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }) +}) + +test('computeWindowOptions restores an on-screen position', () => { + const saved = sanitizeWindowState({ x: 200, y: 150, width: 1400, height: 900 }) + assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900, x: 200, y: 150 }) +}) + +test('computeWindowOptions keeps the size but drops an off-screen position', () => { + const saved = sanitizeWindowState({ x: 5000, y: 150, width: 1400, height: 900 }) + assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900 }) +}) + +test('computeWindowOptions clamps a size larger than the only display', () => { + const saved = sanitizeWindowState({ width: 2560, height: 1440 }) + assert.deepEqual(computeWindowOptions(saved, LAPTOP), { width: 1366, height: 728 }) +}) + +test('computeWindowOptions keeps the MIN floor on a sub-minimum display', () => { + const tiny = [{ workArea: { x: 0, y: 0, width: 360, height: 480 } }] + const saved = sanitizeWindowState({ width: 2000, height: 1500 }) + assert.deepEqual(computeWindowOptions(saved, tiny), { width: MIN_WIDTH, height: MIN_HEIGHT }) +}) + +test('computeWindowOptions does not clamp when displays are unknown', () => { + const saved = sanitizeWindowState({ width: 2560, height: 1440 }) + assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 }) +}) + +// ─── debounce ────────────────────────────────────────────────────────────── + +test('debounce coalesces a burst into one trailing run', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + let calls = 0 + const d = debounce(() => { calls += 1 }, 250) + + d(); d(); d() + assert.equal(calls, 0) + t.mock.timers.tick(249) + assert.equal(calls, 0) + t.mock.timers.tick(1) + assert.equal(calls, 1) +}) + +test('debounce.flush runs now and cancels the pending timer', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + let calls = 0 + const d = debounce(() => { calls += 1 }, 250) + + d() + d.flush() + assert.equal(calls, 1) + t.mock.timers.tick(1000) + assert.equal(calls, 1) +}) diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 4239da56e23..383f2f2d3d0 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -12,7 +12,8 @@ function readElectronFile(name) { } function requireHiddenChildOptions(source, needle) { - const index = source.indexOf(needle) + const match = needle instanceof RegExp ? needle.exec(source) : null + const index = needle instanceof RegExp ? match?.index ?? -1 : source.indexOf(needle) assert.notEqual(index, -1, `missing call site: ${needle}`) const snippet = source.slice(index, index + 700) assert.match( @@ -28,14 +29,28 @@ test('desktop background child processes opt into hidden Windows consoles', () = assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) requireHiddenChildOptions(source, "execFileSync(\n 'reg'") - requireHiddenChildOptions(source, 'execFileSync(pyExe') - requireHiddenChildOptions(source, 'spawn(resolveGitBinary()') + requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/) + requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/) requireHiddenChildOptions(source, "execFileSync('taskkill'") - requireHiddenChildOptions(source, 'spawn(command, args') + requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/) requireHiddenChildOptions(source, "spawn('curl'") - requireHiddenChildOptions(source, 'spawn(backend.command, backend.args') - requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args') - requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']") + requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) + requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) + requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/) + + assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/) + assert.match(source, /existing Hermes no-console Python at/) + assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/) + assert.match(source, /function toNoConsolePython\(pythonPath\)/) + assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/) + assert.match(source, /function readVenvHome\(venvRoot\)/) + assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/) + assert.match(source, /backendStartFailure/) + assert.match(source, /HERMES_DESKTOP_READY_FILE/) + assert.match(source, /readyFile: true/) + assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) + assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) + assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/) }) test('intentional or interactive desktop child processes stay documented', () => { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 81e855451f8..214bd01fb39 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", + "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild", "postbuild": "node scripts/assert-dist-built.cjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs", @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", @@ -93,6 +93,7 @@ "remark-math": "^6.0.0", "remend": "^1.3.0", "shiki": "^4.0.2", + "simple-git": "^3.36.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 00000000000..bb5b0ad061b --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.cjs into a single +// self-contained file so the nix build doesn't need to ship node_modules/. +// +// `electron` is provided by the runtime; `node-pty` is staged separately +// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main — +// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays +// as a separate file and doesn't need bundling. +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { renameSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const entry = resolve(root, 'electron/main.cjs') +const tmp = resolve(root, 'electron/main.bundled.cjs') + +await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: tmp, + external: ['electron', 'node-pty'], + logLevel: 'info' +}) + +// Overwrite the original with the bundled version. +renameSync(tmp, entry) + +console.log(`bundled ${entry}`) diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 6a1fbf9eeea..ed31a007bd5 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -4,14 +4,15 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { FadeText } from '@/components/ui/fade-text' +import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { type Translations, useI18n } from '@/i18n' -import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' +import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' -import { $activeSessionId } from '@/store/session' import { $subagentsBySession, + allSubagents, buildSubagentTree, type SubagentNode, type SubagentStatus, @@ -77,15 +78,12 @@ interface AgentsViewProps { export function AgentsView({ onClose }: AgentsViewProps) { const { t } = useI18n() - const activeSessionId = useStore($activeSessionId) const subagentsBySession = useStore($subagentsBySession) - const activeSubagents = useMemo( - () => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []), - [activeSessionId, subagentsBySession] - ) - - const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents]) + // Aggregate every session, matching the status-bar indicator — a subagent + // running in a background session must still be visible here, or the two + // desync ("Agents N running" vs an empty tree). + const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession]) return ( - +

{t.agents.emptyTitle}

{t.agents.emptyDesc}

diff --git a/apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx b/apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx new file mode 100644 index 00000000000..6c598c5a8fa --- /dev/null +++ b/apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from '@testing-library/react' +import { useCallback, useRef } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(cleanup) + +// Regression repro for #49903: on desktop v0.17.0 the composer threw an +// uncaught `Error: Composer is not available` at startup and the input went +// unresponsive. The throw comes from @assistant-ui/core's composer-runtime — +// every *mutator* (setText/send/…) does `if (!core) throw new Error("Composer +// is not available")` when the thread's composer core isn't bound yet. Unlike +// the read path (`s.composer.text`, which is null-safe: `runtime?.text ?? ""`), +// the mutators have no graceful fallback. ChatBar's mount-time effects (draft +// restore, clearDraft, external inserts) push text via `aui.composer().setText` +// before the core binds, and the popout refactor (#49488) widened that window, +// so the throw surfaced as an uncaught error that wedged the input. +// +// The fix wraps every `aui.composer().setText` call in a `setComposerText` +// helper that swallows the unbound-core throw — the contentEditable DOM + +// draftRef already hold the text and the draft⇄editor sync re-applies it once +// the core attaches, so nothing is lost. This Harness mirrors that helper +// faithfully (same try/catch shape) over a fake `aui` whose composer can be +// toggled bound/unbound, the way the assistant-ui runtime behaves across mount. + +interface FakeComposer { + setText: (value: string) => void +} + +// Mirror of index.tsx's `useAui()` composer surface: composer() returns a +// runtime whose setText throws exactly like @assistant-ui/core when unbound. +function makeFakeAui(bound: { current: boolean }, applied: string[]) { + const composer: FakeComposer = { + setText(value: string) { + if (!bound.current) { + throw new Error('Composer is not available') + } + + applied.push(value) + } + } + + return { composer: () => composer } +} + +function Harness({ + bound, + applied, + onError +}: { + applied: string[] + bound: { current: boolean } + onError: (err: unknown) => void +}) { + const aui = useRef(makeFakeAui(bound, applied)).current + + // Verbatim mirror of the production `setComposerText` helper in index.tsx. + const setComposerText = useCallback( + (value: string) => { + try { + aui.composer().setText(value) + } catch { + // Composer core not bound yet — swallow so the input stays usable. + } + }, + [aui] + ) + + // A draft-restore-on-mount that fires while the core may still be unbound, + // exactly like loadIntoComposer/clearDraft do on startup. + try { + setComposerText('restored draft') + } catch (err) { + onError(err) + } + + return null +} + +describe('setComposerText guard (#49903)', () => { + it('swallows the unbound-core throw at startup instead of crashing the renderer', () => { + const applied: string[] = [] + const bound = { current: false } + const onError = vi.fn() + + expect(() => render()).not.toThrow() + + // The guard absorbed the throw — nothing escaped to the renderer, and no + // assistant-ui write landed (core was unbound). + expect(onError).not.toHaveBeenCalled() + expect(applied).toEqual([]) + }) + + it('writes through to the composer once the core is bound', () => { + const applied: string[] = [] + const bound = { current: true } + const onError = vi.fn() + + act(() => { + render() + }) + + expect(onError).not.toHaveBeenCalled() + expect(applied).toEqual(['restored draft']) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 916436de0b2..d3969b70020 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,8 +10,8 @@ * steal focus from the composer effect. */ -import { RICH_INPUT_SLOT } from './rich-editor' import type { InlineRefInput } from './inline-refs' +import { RICH_INPUT_SLOT } from './rich-editor' export type ComposerTarget = 'edit' | 'main' export type ComposerInsertMode = 'block' | 'inline' @@ -34,6 +34,13 @@ interface InsertRefsDetail { const FOCUS_EVENT = 'hermes:composer-focus' const INSERT_EVENT = 'hermes:composer-insert' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' +const SUBMIT_EVENT = 'hermes:composer-submit' +const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' + +interface SubmitDetail { + target: ComposerTarget + text: string +} let activeTarget: ComposerTarget = 'main' @@ -105,6 +112,30 @@ export const requestComposerInsertRefs = ( export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) => subscribe(INSERT_REFS_EVENT, handler) +/** Submit a prompt through a composer as if the user typed + sent it. Lets + * external panels (e.g. the review pane's "let the agent ship it" button) hand + * the agent a task without the user round-tripping through the input. */ +export const requestComposerSubmit = ( + text: string, + { target = 'active' }: { target?: ComposerTarget | 'active' } = {} +) => { + const trimmed = text.trim() + + if (trimmed) { + dispatch(SUBMIT_EVENT, { target: resolve(target), text: trimmed }) + } +} + +export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) => + subscribe(SUBMIT_EVENT, handler) + +/** Toggle the active composer's voice conversation — the `composer.voice` + * hotkey (Ctrl+B) reaching into the composer that owns the voice state. */ +export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() }) + +export const onComposerVoiceToggleRequest = (handler: () => void) => + subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler()) + /** * Focus a composer input across React commit + browser focus restore. * diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 4010f2f783e..8a0ec509b0b 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -45,8 +45,8 @@ import { $composerPoppedOut, POPOUT_WIDTH_REM, readPopoutBounds, - setComposerPoppedOut, - setComposerPopoutPosition + setComposerPopoutPosition, + setComposerPoppedOut } from '@/store/composer-popout' import { $queuedPromptsBySession, @@ -60,8 +60,10 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' -import { $previewStatusBySession } from '@/store/preview-status' import { notify } from '@/store/notifications' +import { $previewStatusBySession } from '@/store/preview-status' +import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' +import { toggleReview } from '@/store/review' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { isSecondaryWindow } from '@/store/windows' @@ -79,7 +81,9 @@ import { markActiveComposer, onComposerFocusRequest, onComposerInsertRefsRequest, - onComposerInsertRequest + onComposerInsertRequest, + onComposerSubmitRequest, + onComposerVoiceToggleRequest } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' @@ -107,6 +111,7 @@ import { slashChipElement } from './rich-editor' import { ComposerStatusStack } from './status-stack' +import { CodingStatusRow } from './status-stack/coding-row' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' @@ -193,6 +198,32 @@ export function ChatBar({ }: ChatBarProps) { const aui = useAui() const draft = useAuiState(s => s.composer.text) + + // assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not + // available" when the thread's composer core isn't bound yet — and unlike the + // read path (`s.composer.text`, which is null-safe), there's no graceful + // fallback. There's a startup/thread-swap window where this ChatBar's mount + // effects (draft restore, clearDraft, external inserts) run before the core + // binds; the popout refactor (#49488) widened it by moving the composer out + // of the contain wrapper into a sibling of the thread, so the throw began + // surfacing as an uncaught error that wedged the desktop input (#49903). + // + // Guard every mutation: if the core isn't ready, no-op the assistant-ui write. + // The contentEditable DOM + draftRef already hold the text, and the + // draft⇄editor sync reconciles composer state once the core attaches, so the + // draft is never lost — only the (premature) state push is skipped. + const setComposerText = useCallback( + (value: string) => { + try { + aui.composer().setText(value) + } catch { + // Composer core not bound yet — DOM/draftRef carry the text; the sync + // effect re-applies it after bind. Swallow so the input stays usable. + } + }, + [aui] + ) + const attachments = useStore($composerAttachments) const queuedPromptsBySession = useStore($queuedPromptsBySession) const statusItemsBySession = useStore($statusItemsBySession) @@ -370,7 +401,7 @@ export function ChatBar({ const next = `${base}${sep}${value}` draftRef.current = next - aui.composer().setText(next) + setComposerText(next) const editor = editorRef.current @@ -381,7 +412,7 @@ export function ChatBar({ setFocusRequestId(id => id + 1) }, - [aui] + [setComposerText] ) useEffect(() => { @@ -591,7 +622,7 @@ export function ChatBar({ const nextDraft = `${currentDraft}${sep}${text}` draftRef.current = nextDraft - aui.composer().setText(nextDraft) + setComposerText(nextDraft) // Push the new text into the contentEditable editor directly. Setting the // assistant-ui composer state alone is not enough: the draft→editor sync @@ -624,7 +655,7 @@ export function ChatBar({ } draftRef.current = nextDraft - aui.composer().setText(nextDraft) + setComposerText(nextDraft) requestMainFocus() return true @@ -710,7 +741,7 @@ export function ChatBar({ if (nextDraft !== draftRef.current) { draftRef.current = nextDraft - aui.composer().setText(nextDraft) + setComposerText(nextDraft) } window.setTimeout(refreshTrigger, 0) @@ -836,7 +867,7 @@ export function ChatBar({ renderComposerContents(editor, prefix) placeCaretEnd(editor) draftRef.current = composerPlainText(editor) - aui.composer().setText(draftRef.current) + setComposerText(draftRef.current) closeTrigger() runAction() requestMainFocus() @@ -864,7 +895,7 @@ export function ChatBar({ const finish = () => { draftRef.current = composerPlainText(editor) - aui.composer().setText(draftRef.current) + setComposerText(draftRef.current) requestMainFocus() keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger() } @@ -1316,17 +1347,91 @@ export function ChatBar({ } const clearDraft = useCallback(() => { - aui.composer().setText('') + setComposerText('') draftRef.current = '' if (editorRef.current) { editorRef.current.replaceChildren() } - }, [aui]) + }, [setComposerText]) + + // Hand a worktree off to the controller: open a fresh session anchored there, + // carrying the composer draft as its first turn. Clearing here means the draft + // travels to the new session instead of getting stashed under this one. + const openInWorktree = useCallback( + (path: string) => { + const text = draftRef.current + clearDraft() + clearComposerAttachments() + requestStartWorkSession(path, text) + }, + [clearDraft] + ) + + // Branch off into a NEW worktree (base = branch name, or current HEAD). A + // create failure throws back to the row (which toasts) before we touch the + // draft; a missing cwd / remote backend no-ops (the row hides the affordance). + const handleBranchOff = useCallback( + async (branch: string, base?: string) => { + const repoPath = cwd?.trim() + const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + // Convert an EXISTING branch into a fresh worktree + session (no new branch). + // Mirrors handleBranchOff's hand-off: create the worktree, then open a session + // anchored there carrying the draft. + const handleConvertBranch = useCallback( + async (branch: string, path?: null | string, isDefault?: boolean) => { + if (path?.trim()) { + openInWorktree(path) + + return + } + + const repoPath = cwd?.trim() + + if (repoPath && isDefault) { + await switchBranchInRepo(repoPath, branch) + openInWorktree(repoPath) + + return + } + + const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + const handleListBranches = useCallback(async () => { + const repoPath = cwd?.trim() + + return repoPath ? listRepoBranches(repoPath) : [] + }, [cwd]) + + const handleSwitchBranch = useCallback( + async (branch: string) => { + const repoPath = cwd?.trim() + + if (repoPath) { + await switchBranchInRepo(repoPath, branch) + } + }, + [cwd] + ) const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { draftRef.current = text - aui.composer().setText(text) + setComposerText(text) $composerAttachments.set(cloneAttachments(attachments)) const editor = editorRef.current @@ -1647,6 +1752,41 @@ export function ChatBar({ } }, [autoDrainNext, busy, queuedPrompts.length]) + // Esc cancels the in-flight turn when the CHAT has focus — not just the + // composer input (which has its own handler above). Clicking into the + // transcript and hitting Esc now stops the run, matching the Stop button. + // Intentional only: we bail if (a) the composer/another field already + // handled Esc (defaultPrevented), (b) focus is in any input/textarea/ + // contenteditable (you're typing, not stopping), or (c) a dialog/popover is + // open — Esc must close that overlay, never double as canceling the stream + // behind it. A latest-handler ref keeps the listener registered once. + const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) + escCancelRef.current = (event: globalThis.KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !busy) { + return + } + + const active = document.activeElement as HTMLElement | null + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + return + } + + if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) { + return + } + + event.preventDefault() + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event) + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + // Queue-edit cleanup: on session swap the scope effect already stashed the // edit snapshot; only restore into the composer when still on the same scope. useEffect(() => { @@ -1679,6 +1819,22 @@ export function ChatBar({ .catch(restore) } + // External "submit this prompt" requests (e.g. the review pane's agent-ship + // button) route through the same send path. A ref keeps the listener stable + // while always calling the latest dispatchSubmit closure. + const dispatchSubmitRef = useRef(dispatchSubmit) + dispatchSubmitRef.current = dispatchSubmit + + useEffect( + () => + onComposerSubmitRequest(({ target, text }) => { + if (target === 'main' && !inputDisabled) { + dispatchSubmitRef.current(text) + } + }), + [inputDisabled] + ) + const submitDraft = () => { if (disabled) { return @@ -1699,7 +1855,7 @@ export function ChatBar({ if (domText !== draftRef.current) { draftRef.current = domText - aui.composer().setText(domText) + setComposerText(domText) } } @@ -1818,6 +1974,24 @@ export function ChatBar({ pendingResponse }) + // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting + // with STT unconfigured lets the conversation surface its own "configure + // speech-to-text" notice rather than silently no-opping. + const toggleVoiceConversation = useCallback(() => { + if (disabled) { + return + } + + if (voiceConversationActive) { + setVoiceConversationActive(false) + void conversation.end() + } else { + setVoiceConversationActive(true) + } + }, [conversation, disabled, voiceConversationActive]) + + useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) + const contextMenu = (
+
{ + if (branch.checkedOut) { + return copy.branchOpenExisting + } + + return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree +} + +interface CodingStatusRowProps { + /** Branch the current draft off into a fresh worktree + session, based on + * `base` (a branch name; omitted = current HEAD). The composer owns the + * draft, so it supplies the orchestration; the row just collects the new + * branch name + base. Omitted (e.g. remote backend) hides the affordance. */ + onBranchOff?: (branch: string, base?: string) => Promise + /** Check an existing branch out into a fresh worktree + session (no new + * branch). Drives the dialog's "convert a branch" picker. */ + onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise + /** List the repo's local branches for the "convert a branch" picker. */ + onListBranches?: () => Promise + /** Open the review pane (changed files + diffs). */ + onOpen?: () => void + /** Jump into an existing worktree (open a fresh session anchored there). */ + onOpenWorktree?: (path: string) => void + /** Switch the current repo checkout to another branch. */ + onSwitchBranch?: (branch: string) => Promise +} + +/** + * The always-on coding-context row, the BASE of the composer status stack: + * current branch, dirty summary (+/-), and ahead/behind. A touch more prominent + * than the per-turn rows above it (larger branch label, accent glyph), and the + * entry point to the review pane. Hidden when the active session isn't in a + * local git repo (the probe returns null). + */ +export const CodingStatusRow = memo(function CodingStatusRow({ + onBranchOff, + onConvertBranch, + onListBranches, + onOpen, + onOpenWorktree, + onSwitchBranch +}: CodingStatusRowProps) { + const { t } = useI18n() + const s = t.statusStack.coding + const p = t.sidebar.projects + const status = useStore($repoStatus) + const worktrees = useStore($repoWorktrees) + + const [branchOpen, setBranchOpen] = useState(false) + const [branchName, setBranchName] = useState('') + const [branchBase, setBranchBase] = useState(undefined) + const [branchPending, setBranchPending] = useState(false) + const [convertMode, setConvertMode] = useState(false) + const [branches, setBranches] = useState([]) + const [branchesLoading, setBranchesLoading] = useState(false) + + const loadBranches = useCallback(async () => { + if (!onListBranches) { + return + } + + setBranchesLoading(true) + + try { + setBranches(await onListBranches()) + } catch { + setBranches([]) + } finally { + setBranchesLoading(false) + } + }, [onListBranches]) + + // Open the name dialog for a chosen base. Deferred so the dropdown finishes + // closing before the dialog grabs focus (Radix focus-trap handoff races + // otherwise). + const startBranch = (base: string | undefined) => { + setBranchBase(base) + setBranchName('') + setConvertMode(false) + setTimeout(() => setBranchOpen(true), 0) + } + + const startConvert = () => { + setBranchBase(undefined) + setBranchName('') + setConvertMode(true) + void loadBranches() + setTimeout(() => setBranchOpen(true), 0) + } + + const enterConvert = () => { + setConvertMode(true) + void loadBranches() + } + + const convertBranch = async (branch: HermesGitBranch) => { + if (branchPending || !branch || !onConvertBranch) { + return + } + + setBranchPending(true) + + try { + await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault) + setBranchOpen(false) + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setBranchPending(false) + } + } + + // Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off + // current HEAD. The rail only renders inside a repo, so the hotkey naturally + // no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on + // mount or unrelated re-renders. + const worktreeReq = useStore($newWorktreeRequest) + const lastWorktreeReqRef = useRef(worktreeReq) + + useEffect(() => { + if (worktreeReq === lastWorktreeReqRef.current) { + return + } + + lastWorktreeReqRef.current = worktreeReq + + if (!onBranchOff) { + return + } + + setBranchBase(undefined) + setBranchName('') + setConvertMode(false) + setBranchOpen(true) + }, [onBranchOff, worktreeReq]) + + const submitBranch = async () => { + const branch = branchName.trim() + + if (branchPending || !branch || !onBranchOff) { + return + } + + setBranchPending(true) + + try { + await onBranchOff(branch, branchBase) + setBranchOpen(false) + setBranchName('') + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setBranchPending(false) + } + } + + const switchToBranch = async (branch: string) => { + if (!onSwitchBranch) { + return + } + + try { + await onSwitchBranch(branch) + } catch (err) { + notifyError(err, s.switchFailed(branch)) + } + } + + if (!status) { + return null + } + + const branchLabel = status.detached ? s.detached : status.branch || s.noBranch + // The kebab offers branching off the trunk and/or the current branch. The + // worktree-add bases the new branch on `base` (a branch name; undefined = + // current HEAD). We dedupe so "on main" shows a single trunk entry, and fall + // back to a plain off-HEAD branch when no trunk is detected. + const current = status.detached ? null : status.branch + const branchTargets: { base: string | undefined; label: string }[] = [] + + // Current branch first (the 99% "branch off where I am"), then the trunk just + // below it ("New branch from main"), deduped when they're the same. + if (current) { + branchTargets.push({ base: current, label: s.branchOffFrom(current) }) + } + + if (status.defaultBranch && status.defaultBranch !== current) { + branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) }) + } + + if (branchTargets.length === 0) { + branchTargets.push({ base: undefined, label: s.newBranch }) + } + + const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null + + // Other worktrees to jump into — everything except the one we're already in + // (matched by its checked-out branch) and the bare/main placeholder entry. + const otherWorktrees = onOpenWorktree + ? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current) + : [] + + const hasLineDelta = status.added > 0 || status.removed > 0 + // Untracked files carry no line delta vs HEAD, so surface them as a count when + // they're the only change (otherwise +/- tells the story). + const untrackedOnly = !hasLineDelta && status.untracked > 0 + + return ( + <> + } + onActivate={onOpen} + > +
+ + {branchLabel} + + + {/* Branch actions kebab — same pattern as the session/worktree rows. + ALWAYS laid out; only its opacity flips on hover/focus/open, so + revealing it never reflows the row (no layout shift). pointer-events + follow opacity so the invisible trigger isn't clickable at rest. */} + {onBranchOff && ( + + + + + {/* The row sits at the bottom of the screen (above the composer), + so the menu opens upward. */} + + {s.newBranch} + {branchTargets.map(target => ( + startBranch(target.base)}> + {target.label} + + ))} + + {switchTarget && ( + void switchToBranch(switchTarget)}> + {s.switchTo(switchTarget)} + + )} + + + {s.worktrees} + {otherWorktrees.map(worktree => ( + onOpenWorktree?.(worktree.path)}> + {worktree.branch} + + ))} + {/* Create a fresh worktree off the current HEAD (the generic + "spin up a worktree here", mirroring the sidebar's + button). */} + startBranch(undefined)}> + {p.startWork} + + {/* Check an EXISTING branch out into a worktree (no new branch). */} + {onConvertBranch && ( + startConvert()}> + {p.convertBranch} + + )} + + + )} +
+ + {(status.ahead > 0 || status.behind > 0) && ( + + {status.ahead > 0 && ( + + + {status.ahead} + + )} + {status.behind > 0 && ( + + + {status.behind} + + )} + + )} + + {hasLineDelta ? ( + + ) : untrackedOnly ? ( + + {s.changed(status.untracked)} + + ) : null} +
+ + !branchPending && setBranchOpen(open)} open={branchOpen}> + + + {convertMode ? p.convertBranchTitle : p.newWorktreeTitle} + + {convertMode ? p.convertBranchDesc : p.newWorktreeDesc} + {!convertMode && branchBase && ( + {s.branchOffFrom(branchBase)} + )} + + + + {convertMode ? ( + (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} + > + + + {branchesLoading ? p.branchesLoading : p.noBranches} + + {branches.map(branch => ( + void convertBranch(branch)} + value={branch.name} + > + + {branch.name} + + {branchActionLabel(branch, p)} + + + ))} + + + + ) : ( + { + if (event.key === 'Enter') { + event.preventDefault() + void submitBranch() + } else if (event.key === 'Escape') { + setBranchOpen(false) + } + }} + onValueChange={setBranchName} + placeholder={p.branchPlaceholder} + sanitize={gitRef} + value={branchName} + /> + )} + + {convertMode ? ( + + + + ) : ( + + {onConvertBranch ? ( + + ) : ( + + )} +
+ + +
+
+ )} +
+
+ + ) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index b9cf2ffb99c..93c8a2dc1af 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -30,6 +30,19 @@ import { StatusItemRow } from './status-row' // emit no event when they die). Only armed while a running row is on screen. const BACKGROUND_POLL_MS = 5_000 +// A localhost/loopback preview is only meaningful while its dev server is up, so +// we tie it to a live background process rather than persisting dismissals or +// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone. +const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target) + +// Real codicons per group (no sparkles): a checklist for todos, a bot for +// subagents, a background process glyph for background tasks. +const GROUP_ICON: Record = { + todo: 'checklist', + subagent: 'hubot', + background: 'server-process' +} + const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { if (group.type === 'todo') { return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) @@ -74,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running')) + // Drop localhost previews once no dev server is left running — that's what made + // dead `localhost:5174` chips stick around. On-disk file previews are kept. + const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target)) + useEffect(() => { if (!sessionId || !hasRunningBackground) { return @@ -89,6 +106,18 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const openSubagent = (item: ComposerStatusItem) => item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents() + // Preview links live as child rows of the background group — a localhost dev + // server and its preview are the same thing — so they no longer float as an + // odd, differently-indented standalone block under the stack. + const previewRows = + visiblePreviews.length > 0 && sessionId + ? visiblePreviews.map(item => ( + dismissPreviewArtifact(sessionId, id)} /> + )) + : [] + + const hasBackgroundGroup = groups.some(g => g.type === 'background') + const sections: { key: string; node: ReactNode }[] = groups.map(group => ({ key: group.type, node: ( @@ -107,11 +136,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro ) : undefined } defaultCollapsed={group.type !== 'todo'} - icon={ - group.type === 'todo' ? ( - - ) : undefined - } + icon={} label={groupLabel(group, t.statusStack)} > {group.items.map(item => ( @@ -120,25 +145,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro key={item.id} onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined} onOpen={() => openSubagent(item)} - onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined} + onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined} /> ))} + {group.type === 'background' && previewRows} ) })) - if (previews.length > 0 && sessionId) { + // No background group to host them (e.g. a standalone on-disk file preview): + // keep the previews as their own row block so they don't disappear. + if (previewRows.length > 0 && !hasBackgroundGroup) { sections.push({ key: 'preview', - // Not a collapsible group — preview links just sit there, one line each, - // each individually closeable. - node: ( -
- {previews.map(item => ( - dismissPreviewArtifact(sessionId, id)} /> - ))} -
- ) + node:
{previewRows}
}) } @@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro return (
blurComposerInput()} ref={stackRef} > @@ -205,17 +223,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro Rounded top, square bottom; the bottom border is TRANSPARENT — the composer surface's visible top border (which sits at a higher z) is the single shared seam, so the two read as one fused capsule. */} -
-
- {sections.map(section => ( -
{section.node}
- ))} -
+
+ {sections.map(section => ( +
{section.node}
+ ))}
) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index cc6893f0e64..f8c3cc520b3 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' -import { ChevronRight, X } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { PREVIEW_PANE_ID } from '@/store/layout' @@ -76,50 +75,47 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss return ( } - onActivate={() => void togglePreview()} + leading={ + + } + // Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the + // in-app preview pane instead. (isOpen still toggles the pane closed.) + onActivate={event => { + if (event.metaKey || event.ctrlKey) { + void togglePreview() + } else { + void openInBrowser() + } + }} trailing={ - - - - - - - - + + + } trailingVisible > - - {item.label} - - - {opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview} - + + {item.target} + {t.preview.linkHint} + + } + > + {item.label} + ) }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 27a9ef0262c..bc54b92ffe9 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' -import { ArrowUpRight, X } from '@/lib/icons' import type { TodoStatus } from '@/lib/todos' import { cn } from '@/lib/utils' import type { ComposerStatusItem } from '@/store/composer-status' @@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): return ( ) @@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp type="button" variant="ghost" > - + ) : canOpen ? ( - + ) : undefined } > diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 2b6586cf5a1..e4a80e61273 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -88,7 +88,10 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise - onRestoreToMessage?: (messageId: string) => Promise + onRestoreToMessage?: ( + messageId: string, + target?: { text?: string; userOrdinal?: number | null } + ) => Promise onRetryResume: (sessionId: string) => void onTranscribeAudio?: (audio: Blob) => Promise onDismissError?: (messageId: string) => void diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 6261200706c..ef0c7d185ff 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react' -import { useEffect, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import ShikiHighlighter from 'react-shiki' import { Streamdown } from 'streamdown' @@ -14,15 +14,21 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection' +import { FileDiffPanel } from '@/components/chat/diff-lines' +import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' import { PageLoader } from '@/components/page-loader' import { translateNow, useI18n } from '@/i18n' -import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' +import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' +import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' import { $currentCwd } from '@/store/session' const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 +const SOURCE_CHUNK_LINES = 200 +const SOURCE_LINE_PX = 20 +const SOURCE_OVERSCAN_LINES = 400 type EmptyStateTone = 'neutral' | 'warning' @@ -126,6 +132,8 @@ interface LocalPreviewState { binary?: boolean byteSize?: number dataUrl?: string + /** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */ + diff?: string error?: string language?: string loading: boolean @@ -299,28 +307,44 @@ function MarkdownPreview({ text }: { text: string }) { ) } -function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) { +function PreviewModeSwitcher({ + active, + modes, + onSelect +}: { + active: PreviewViewMode + modes: PreviewViewMode[] + onSelect: (mode: PreviewViewMode) => void +}) { const { t } = useI18n() + const label: Record = { + diff: t.preview.diff, + rendered: t.preview.renderedPreview, + source: t.preview.source + } + return ( -
- +
+ {modes.map(mode => ( + + ))}
) } -// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so -// each line aligns vertically. The selection overlay relies on the same -// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself. -const SOURCE_LINE_HEIGHT_REM = 1.21875 -const SOURCE_PAD_Y_REM = 0.75 - interface LineSelection { end: number start: number @@ -337,7 +361,18 @@ function startLineDrag(event: ReactDragEvent, filePath: string, { e function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) { const { t } = useI18n() - const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text]) + const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) + const lastChunk = chunks.at(-1) + const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: SOURCE_OVERSCAN_LINES, + rowPx: SOURCE_LINE_PX, + rowsPerChunk: SOURCE_CHUNK_LINES, + totalRows: totalLines + }) + + const visibleChunks = chunks.slice(startChunk, endChunk + 1) const [selection, setSelection] = useState(null) const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end @@ -394,69 +429,76 @@ function SourceView({ filePath, language, text }: { filePath: string; language: }, [filePath, selection]) return ( -
-
- {Array.from({ length: lineCount }, (_, index) => { - const line = index + 1 - const selected = inSelection(line) - - return ( -
handleLineClick(event, line)} - onDragStart={event => handleDragStart(event, line)} - title={t.preview.sourceLineTitle} - > - {line} -
- ) - })} -
-
- {selection && ( -
+
+
+ {beforeRows > 0 && ( +
+ )} + {visibleChunks.map(chunk => ( + +
+ {chunk.lines.map((_lineText, offset) => { + const line = chunk.start + offset + 1 + const selected = inSelection(line) + + return ( +
handleLineClick(event, line)} + onDragStart={event => handleDragStart(event, line)} + title={t.preview.sourceLineTitle} + > + {line} +
+ ) + })} +
+
+ + {chunk.text} + +
+
+ ))} + {afterRows > 0 && ( +
)} - - {text} -
) } +type PreviewViewMode = 'diff' | 'rendered' | 'source' + export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) { const { t } = useI18n() const [state, setState] = useState({ loading: true }) const [forcePreview, setForcePreview] = useState(false) - const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false) + // User-picked view; null = auto (diff when changed, else rendered markdown, + // else source). Reset when the previewed file changes. + const [userMode, setUserMode] = useState(null) const filePath = filePathForTarget(target) const isImage = target.previewKind === 'image' + useEffect(() => { + setUserMode(null) + }, [filePath, reloadKey]) + // HTML files are rendered as source code, not in a webview - so they take // the same path as plain text files. `previewKind === 'binary'` arrives // when the file is forcibly previewed past the binary refusal screen. @@ -508,6 +550,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar text: shouldBlock ? undefined : result.text, truncated: result.truncated }) + + // Best-effort: fetch the file's working-tree-vs-HEAD diff so the + // preview can offer a DIFF view when there are uncommitted changes. + // Empty (clean file / not a repo / remote) just hides the option. + if (!shouldBlock) { + try { + const root = await desktopGitRoot(filePath) + const diff = root ? await desktopFileDiff(root, filePath) : '' + + if (active && diff.trim()) { + setState(prev => (prev.text === result.text ? { ...prev, diff } : prev)) + } + } catch { + // No diff available; the preview just shows source. + } + } } } catch (error) { if (active) { @@ -571,21 +629,50 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar if (isText && state.text !== undefined) { const isMarkdown = (state.language || target.language) === 'markdown' - const showRendered = isMarkdown && !renderMarkdownAsSource + const hasDiff = Boolean(state.diff && state.diff.trim()) + // Order the toggle reads left→right; default lands on the most useful view. + const modes: PreviewViewMode[] = [] + + if (isMarkdown) { + modes.push('rendered') + } + + modes.push('source') + + if (hasDiff) { + modes.push('diff') + } + + const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source' + const mode = userMode && modes.includes(userMode) ? userMode : autoMode return ( -
+
{state.truncated && (
{t.preview.truncated}
)} - {isMarkdown && setRenderMarkdownAsSource(s => !s)} />} - {showRendered ? ( - - ) : ( - - )} + {modes.length > 1 && } +
+ {mode === 'rendered' ? ( + + ) : mode === 'diff' ? ( + + ) : ( + + )} +
) } diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index dec0e36f47b..97678cab106 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react' import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' import { Codicon } from '@/components/ui/codicon' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' import { Tip } from '@/components/ui/tooltip' import { translateNow, useI18n } from '@/i18n' +import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import { + $panesFlipped, $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, @@ -16,8 +25,10 @@ import { $filePreviewTabs, $previewReloadRequest, $previewTarget, + closeOtherRightRailTabs, closeRightRail, closeRightRailTab, + closeRightRailTabsToRight, type PreviewTarget } from '@/store/preview' @@ -56,6 +67,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const { t } = useI18n() const previewReloadRequest = useStore($previewReloadRequest) const activeTabId = useStore($rightRailActiveTabId) + const panesFlipped = useStore($panesFlipped) const filePreviewTabs = useStore($filePreviewTabs) const previewTarget = useStore($previewTarget) @@ -82,68 +94,92 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID return ( -