diff --git a/.dockerignore b/.dockerignore index a5b50068f020..ec3d52f81413 100644 --- a/.dockerignore +++ b/.dockerignore @@ -66,8 +66,12 @@ runtime/ # ---------- Not needed inside the Docker image ---------- -# Desktop app source (Tauri/Electron); never installed in the container +# Desktop app source (Tauri/Electron); never installed in the container. +# apps/shared is the dashboard↔desktop websocket helper and is linked from +# web/package.json as a file: workspace dep — keep it in the build context. apps/ +!apps/shared/ +!apps/shared/** # Test suite — not shipped in production images tests/ diff --git a/.gitignore b/.gitignore index 489453d79bfe..c820e0a55106 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,9 @@ RELEASE_v*.md # Desktop demo-run scratch output (hermes writes demo/*.txt during recorded # walkthroughs). Throwaway artifacts, never part of the app. apps/desktop/demo/ + +# PR infographics are rendered locally and embedded in PR descriptions via the +# image-provider (fal.media) URL — they are NEVER committed to the repo. The +# PR body is the archive. See the hermes-agent-dev skill's +# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1). +infographic/ diff --git a/AGENTS.md b/AGENTS.md index 21244765491d..d8306d9bdb8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -491,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes ### Electron Desktop Chat App (`apps/desktop/`) -A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. +A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. **Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f56b971d1e6..bad33481c745 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,13 +149,20 @@ this way, make sure you run the `hermes` entrypoint from this venv; running the system `python3 -m hermes_cli.main` can pick up unrelated system Python packages. +Create the venv **outside** the cloned source tree. A venv that lives inside +the directory the agent operates from can be wiped by a relative-path command +the agent runs against its own checkout (`rm -rf venv`, `uv venv venv`, etc.), +which silently destroys the running runtime mid-session. Keeping it outside the +tree means no relative path from the workspace resolves to it. + ```bash git clone https://github.com/NousResearch/hermes-agent.git cd hermes-agent -# Create venv with Python 3.11 -uv venv venv --python 3.11 -export VIRTUAL_ENV="$(pwd)/venv" +# Create venv with Python 3.11, OUTSIDE the source tree +uv venv ~/.hermes/venvs/hermes-dev --python 3.11 +export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev" +export PATH="$VIRTUAL_ENV/bin:$PATH" # Install with all extras (messaging, cron, CLI menus, dev tools) uv pip install -e ".[all,dev]" diff --git a/Dockerfile b/Dockerfile index 6a5f5f1eef57..6f957f779678 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,6 +119,9 @@ COPY package.json package-lock.json ./ COPY web/package.json web/ COPY ui-tui/package.json ui-tui/ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/ +# apps/shared/ is copied IN FULL because web/package.json references it as a +# `file:` workspace dependency (same pattern as hermes-ink above). +COPY apps/shared/ apps/shared/ # `npm_config_install_links=false` forces npm to install `file:` deps as # symlinks instead of copies. This is the default since npm 10+, which is @@ -184,6 +187,7 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra # invalidate the (relatively slow) web + ui-tui build layer. COPY web/ web/ COPY ui-tui/ ui-tui/ +COPY apps/shared/ apps/shared/ RUN cd web && npm run build && \ cd ../ui-tui && npm run build diff --git a/README.md b/README.md index 4caad13ce20e..ba1322a38920 100644 --- a/README.md +++ b/README.md @@ -232,10 +232,14 @@ scripts/run_tests.sh Manual clone fallback (for throwaway clones/CI where you intentionally do not want the managed install layout): +Create the venv outside the cloned source tree — a venv inside the directory +the agent operates from can be wiped by a relative-path command the agent runs +against its own checkout, destroying the running runtime mid-session. + ```bash curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv .venv --python 3.11 -source .venv/bin/activate +uv venv ~/.hermes/venvs/hermes-dev --python 3.11 +source ~/.hermes/venvs/hermes-dev/bin/activate uv pip install -e ".[all,dev]" scripts/run_tests.sh ``` diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 21a14c977089..7a65d3f2e5ed 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1281,7 +1281,11 @@ def dump_api_request_debug( dump_payload["error"] = error_info timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json" + # Sanitize the session ID into a traversal-free path segment — it can + # originate from untrusted input (X-Hermes-Session-Id header), and an + # unsanitized "../"-shaped ID would write the dump outside logs_dir. + safe_sid = _ra()._safe_session_filename_component(agent.session_id) + dump_file = agent.logs_dir / f"request_dump_{safe_sid}_{timestamp}.json" # Redact secrets before persisting/printing. This dump captures the # full request body (system prompt, tool defs, context-embedded diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index dfeec87e12d3..1807e7be2ee3 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -5489,10 +5489,24 @@ def _build_call_kwargs( # ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where # max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only # there. + # + # NVIDIA NIM (integrate.api.nvidia.com and local NIM endpoints) is a + # second exception: some models—notably minimaxai/minimax-m3—return HTTP + # 200 with an empty choices[] payload when max_tokens is omitted. The main + # NVIDIA chat path already sends an output cap via the provider profile; + # preserve it on the auxiliary path too. _effective_base = base_url or ( _current_custom_base_url() if provider == "custom" else "" ) - if _is_anthropic_compat_endpoint(provider, _effective_base): + _provider_norm = str(provider or "").strip().lower() + _is_nvidia_nim = ( + _provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"} + or base_url_host_matches(_effective_base, "integrate.api.nvidia.com") + ) + if ( + _is_anthropic_compat_endpoint(provider, _effective_base) + or _is_nvidia_nim + ): kwargs["max_tokens"] = max_tokens if tools: diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 6c6ba9e12b4e..7a5e75347237 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -28,6 +28,7 @@ from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH from agent.error_classifier import FailoverReason +from agent.gemini_native_adapter import is_native_gemini_base_url from agent.model_metadata import is_local_endpoint from agent.message_sanitization import ( _sanitize_surrogates, @@ -1911,7 +1912,6 @@ def _call_chat_completions(): stream_kwargs = { **api_kwargs, "stream": True, - "stream_options": {"include_usage": True}, "timeout": _httpx.Timeout( connect=_conn_cap, read=_stream_read_timeout, @@ -1919,6 +1919,14 @@ def _call_chat_completions(): pool=_conn_cap, ), } + # OpenAI's `stream_options={"include_usage": True}` drives usage + # accounting on OpenAI-compatible endpoints (incl. the Gemini OpenAI + # compat shim and aggregators like OpenRouter). Google's *native* + # Gemini REST endpoint rejects the keyword outright + # (`Completions.create() got an unexpected keyword argument + # 'stream_options'`), so omit it only for that endpoint. + if not is_native_gemini_base_url(agent.base_url): + stream_kwargs["stream_options"] = {"include_usage": True} request_client = _set_request_client( agent._create_request_openai_client( reason="chat_completion_stream_request", @@ -2319,7 +2327,15 @@ def _call_anthropic(): _fire_first_delta() agent._fire_reasoning_delta(thinking_text) - # Return the native Anthropic Message for downstream processing + # Return the native Anthropic Message for downstream processing. + # If the stream was interrupted (the event loop broke out above on + # agent._interrupt_requested), do NOT call get_final_message() — on + # a partially-consumed stream the SDK may hang draining remaining + # events or return a Message with incomplete tool_use blocks (partial + # JSON in `input`). The outer poll loop raises InterruptedError, so + # this return value is discarded anyway. + if agent._interrupt_requested: + return None return stream.get_final_message() def _call(): diff --git a/agent/context_breakdown.py b/agent/context_breakdown.py new file mode 100644 index 000000000000..0e2eb772f2ff --- /dev/null +++ b/agent/context_breakdown.py @@ -0,0 +1,156 @@ +"""Live session context-window breakdown for UI surfaces. + +Estimates how the next provider request is composed: system prompt tiers, +tool schemas, and conversation history. Uses the same rough char/4 heuristic +as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align +with compression thresholds — not exact tokenizer counts. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional, Sequence, Tuple + +_SKILLS_BLOCK_RE = re.compile(r".*?", re.DOTALL) + +_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"}) + +_CATEGORY_COLORS = { + "system_prompt": "var(--context-usage-system)", + "tool_definitions": "var(--context-usage-tools)", + "rules": "var(--context-usage-rules)", + "skills": "var(--context-usage-skills)", + "mcp": "var(--context-usage-mcp)", + "subagent_definitions": "var(--context-usage-subagents)", + "memory": "var(--context-usage-memory)", + "conversation": "var(--context-usage-conversation)", +} + + +def _chars_to_tokens(text: str) -> int: + if not text: + return 0 + return (len(text) + 3) // 4 + + +def _json_tokens(value: Any) -> int: + if not value: + return 0 + return _chars_to_tokens(json.dumps(value, ensure_ascii=False)) + + +def _tool_name(tool: dict) -> str: + fn = tool.get("function") if isinstance(tool, dict) else None + if isinstance(fn, dict): + return str(fn.get("name") or "") + return str(tool.get("name") or "") + + +def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]: + builtin: List[dict] = [] + mcp: List[dict] = [] + subagent: List[dict] = [] + for tool in tools: + name = _tool_name(tool) + if name.startswith("mcp_"): + mcp.append(tool) + elif name in _SUBAGENT_TOOL_NAMES: + subagent.append(tool) + else: + builtin.append(tool) + return builtin, mcp, subagent + + +def _memory_blocks(agent: Any) -> Tuple[str, str]: + memory_block = "" + user_block = "" + store = getattr(agent, "_memory_store", None) + if store is None: + return memory_block, user_block + try: + if getattr(agent, "_memory_enabled", True): + memory_block = store.format_for_system_prompt("memory") or "" + if getattr(agent, "_user_profile_enabled", True): + user_block = store.format_for_system_prompt("user") or "" + except Exception: + pass + return memory_block, user_block + + +def _strip_blocks(text: str, *blocks: str) -> str: + out = text + for block in blocks: + if block: + out = out.replace(block, "") + return out.strip() + + +def compute_session_context_breakdown( + agent: Any, + messages: Optional[List[dict]] = None, +) -> Dict[str, Any]: + """Return a Cursor-style context usage breakdown for one live agent.""" + from agent.model_metadata import estimate_messages_tokens_rough + from agent.system_prompt import build_system_prompt_parts + + parts = build_system_prompt_parts(agent) + stable = parts.get("stable", "") or "" + context = parts.get("context", "") or "" + volatile = parts.get("volatile", "") or "" + + skills_match = _SKILLS_BLOCK_RE.search(stable) + skills_index = skills_match.group(0) if skills_match else "" + + memory_block, user_block = _memory_blocks(agent) + memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip() + + system_core = _strip_blocks(stable, skills_index) + system_tail = _strip_blocks(volatile, memory_block, user_block) + system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip() + + tools = list(getattr(agent, "tools", None) or []) + builtin_tools, mcp_tools, subagent_tools = _split_tools(tools) + + conversation_tokens = estimate_messages_tokens_rough(messages or []) + + categories = [ + ("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)), + ("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)), + ("rules", "Rules", _chars_to_tokens(context)), + ("skills", "Skills", _chars_to_tokens(skills_index)), + ("mcp", "MCP", _json_tokens(mcp_tools)), + ("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)), + ("memory", "Memory", _chars_to_tokens(memory_text)), + ("conversation", "Conversation", conversation_tokens), + ] + + estimated_total = sum(tokens for _, _, tokens in categories) + + comp = getattr(agent, "context_compressor", None) + context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0 + measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0 + context_used = measured_used if measured_used > 0 else estimated_total + context_percent = ( + max(0, min(100, round(context_used / context_max * 100))) + if context_max + else 0 + ) + + return { + "categories": [ + { + "color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"), + "id": category_id, + "label": label, + "tokens": tokens, + } + for category_id, label, tokens in categories + if tokens > 0 + ], + "context_max": context_max, + "context_percent": context_percent, + "context_used": context_used, + "estimated_total": estimated_total, + "model": getattr(agent, "model", "") or "", + } diff --git a/agent/context_references.py b/agent/context_references.py index fad1ff00159b..d77857584a77 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -328,9 +328,9 @@ async def _fetch_url_content( async def _default_url_fetcher(url: str) -> str: from tools.web_tools import web_extract_tool - raw = await web_extract_tool([url], format="markdown", use_llm_processing=True) + raw = await web_extract_tool([url], format="markdown") payload = json.loads(raw) - docs = payload.get("data", {}).get("documents", []) + docs = payload.get("results", []) if not docs: return "" doc = docs[0] diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index b6e301bd8185..ce3ec2c5c400 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -21,6 +21,11 @@ from types import SimpleNamespace from typing import Any +from openai.types.chat.chat_completion_message_tool_call import ( + ChatCompletionMessageToolCall, + Function, +) + from agent.file_safety import get_read_block_error, is_write_denied from agent.redact import redact_sensitive_text from tools.environments.local import hermes_subprocess_env @@ -228,11 +233,73 @@ def _render_message_content(content: Any) -> str: return str(content).strip() -def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]: +def _build_openai_tool_call( + *, + call_id: str, + name: str, + arguments: str, +) -> ChatCompletionMessageToolCall: + """Build an OpenAI-compatible tool-call object for downstream handling.""" + return ChatCompletionMessageToolCall( + id=call_id, + call_id=call_id, + response_item_id=None, + type="function", + function=Function(name=name, arguments=arguments), + ) + + +def _completion_to_stream_chunks(completion: SimpleNamespace) -> list[SimpleNamespace]: + """Convert a one-shot ACP response into OpenAI-style stream chunks.""" + choice = completion.choices[0] + message = choice.message + tool_call_deltas = None + if message.tool_calls: + tool_call_deltas = [] + for index, tool_call in enumerate(message.tool_calls): + tool_call_deltas.append( + SimpleNamespace( + index=index, + id=getattr(tool_call, "id", None), + type=getattr(tool_call, "type", "function"), + function=SimpleNamespace( + name=getattr(tool_call.function, "name", None), + arguments=getattr(tool_call.function, "arguments", None), + ), + ) + ) + + delta = SimpleNamespace( + role="assistant", + content=message.content or None, + tool_calls=tool_call_deltas, + reasoning_content=message.reasoning_content, + reasoning=message.reasoning, + ) + data_chunk = SimpleNamespace( + choices=[ + SimpleNamespace( + index=0, + delta=delta, + finish_reason=choice.finish_reason, + ) + ], + model=completion.model, + usage=None, + ) + usage_chunk = SimpleNamespace( + choices=[], + model=completion.model, + usage=completion.usage, + ) + return [data_chunk, usage_chunk] + + +def _extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessageToolCall], str]: if not isinstance(text, str) or not text.strip(): return [], "" - extracted: list[SimpleNamespace] = [] + extracted: list[ChatCompletionMessageToolCall] = [] consumed_spans: list[tuple[int, int]] = [] def _try_add_tool_call(raw_json: str) -> None: @@ -256,12 +323,10 @@ def _try_add_tool_call(raw_json: str) -> None: call_id = f"acp_call_{len(extracted)+1}" extracted.append( - SimpleNamespace( - id=call_id, + _build_openai_tool_call( call_id=call_id, - response_item_id=None, - type="function", - function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args), + name=fn_name.strip(), + arguments=fn_args, ) ) @@ -380,6 +445,7 @@ def _create_chat_completion( timeout: float | None = None, tools: list[dict[str, Any]] | None = None, tool_choice: Any = None, + stream: bool = False, **_: Any, ) -> Any: prompt_text = _format_messages_as_prompt( @@ -426,11 +492,14 @@ def _create_chat_completion( ) finish_reason = "tool_calls" if tool_calls else "stop" choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason) - return SimpleNamespace( + completion = SimpleNamespace( choices=[choice], usage=usage, model=model or "copilot-acp", ) + if stream: + return _completion_to_stream_chunks(completion) + return completion def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]: try: diff --git a/agent/curator.py b/agent/curator.py index 6843205c684b..c13a36ecbbd3 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -273,6 +273,21 @@ def should_run_now(now: Optional[datetime] = None) -> bool: # Automatic state transitions (pure function, no LLM) # --------------------------------------------------------------------------- +def _cron_referenced_skills() -> Set[str]: + """Skill names referenced by any cron job (incl. paused/disabled). + + Best-effort: a cron-module import error or corrupt jobs store must never + break the curator, so any failure yields an empty set (no protection, + but no crash). + """ + try: + from cron.jobs import referenced_skill_names as _refs + return _refs() + except Exception as e: + logger.debug("Curator could not read cron skill references: %s", e, exc_info=True) + return set() + + def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]: """Walk every curator-managed skill and move active/stale/archived based on the latest real activity timestamp. Pinned skills are never touched. @@ -292,6 +307,8 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int stale_cutoff = now - timedelta(days=get_stale_after_days()) archive_cutoff = now - timedelta(days=get_archive_after_days()) + cron_referenced = _cron_referenced_skills() + counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0} for row in _u.agent_created_report(): @@ -300,6 +317,15 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int if row.get("pinned"): continue + # A skill referenced by any cron job (incl. paused/disabled) is in + # use by definition — resuming or the next fire must find it. The + # scheduler only bumps usage when a job actually fires, so jobs that + # fire less often than archive_after_days, paused jobs, and far-future + # one-shots would otherwise have their skills aged out from under + # them. Treat referenced skills like pinned: never auto-transition. + if name in cron_referenced: + continue + # First sight of a curation-eligible skill with no persisted record # (e.g. a newly-eligible built-in): anchor its clock to now and defer. if not row.get("_persisted", True): @@ -316,6 +342,18 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int current = row.get("state", _u.STATE_ACTIVE) + # Never-used skills (use_count == 0) get a grace floor: don't archive + # one until it is at least stale_after_days old. A use=0 skill is + # absence of evidence, not evidence of staleness — a skill created + # recently may simply not have had its trigger come up yet. + never_used = int(row.get("use_count", 0) or 0) == 0 + if never_used and anchor > stale_cutoff: + # Younger than the stale window — leave it alone entirely. + if current == _u.STATE_STALE: + _u.set_state(name, _u.STATE_ACTIVE) + counts["reactivated"] += 1 + continue + if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED: ok, _msg = _u.archive_skill(name) if ok: @@ -390,10 +428,19 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int "back load-bearing UX (slash-command entry points referenced in docs and " "tips) and are filtered out of the candidate list below — never resurrect " "one as an archive or absorb target.\n" + "3c. DO NOT archive or prune any skill marked `cron=yes` in the candidate " + "list. A cron job depends on it and will fail to load it on its next " + "run. You MAY still consolidate it into an umbrella — but only because " + "the curator rewrites cron job skill references to follow consolidations; " + "never simply prune it.\n" "4. DO NOT use usage counters as a reason to skip consolidation. The " "counters are new and often mostly zero. Judge overlap on CONTENT, " "not on use_count. 'use=0' is not evidence a skill is valuable; it's " - "absence of evidence either way.\n" + "absence of evidence either way. Corollary: 'use=0' is ALSO not a " + "reason to PRUNE a skill. Never archive a never-used skill (use=0) " + "unless it is at least 30 days old (check last_activity / created date) " + "AND its content is genuinely obsolete or fully absorbed elsewhere — a " + "recently-created skill simply may not have had its trigger come up yet.\n" "5. DO NOT reject consolidation on the grounds that 'each skill has " "a distinct trigger'. Pairwise distinctness is the wrong bar. The " "right bar is: 'would a human maintainer write this as N separate " @@ -1413,12 +1460,14 @@ def _render_candidate_list() -> str: rows = skill_usage.agent_created_report() if not rows: return "No agent-created skills to review." + cron_referenced = _cron_referenced_skills() lines = [f"Agent-created skills ({len(rows)}):\n"] for r in rows: lines.append( f"- {r['name']} " f"state={r['state']} " f"pinned={'yes' if r.get('pinned') else 'no'} " + f"cron={'yes' if r['name'] in cron_referenced else 'no'} " f"activity={r.get('activity_count', 0)} " f"use={r.get('use_count', 0)} " f"view={r.get('view_count', 0)} " diff --git a/agent/image_routing.py b/agent/image_routing.py index 3014acf1cabb..acd66fea8274 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -251,6 +251,78 @@ def _supports_vision_override( return None +def _resolve_inference_base_url( + cfg: Optional[Dict[str, Any]], + provider: str, +) -> str: + """Best-effort base URL for the active inference provider.""" + try: + from agent.auxiliary_client import _RUNTIME_MAIN_BASE_URL + + runtime = str(_RUNTIME_MAIN_BASE_URL or "").strip() + if runtime: + return runtime + except Exception: + pass + + if not isinstance(cfg, dict): + return "" + + model_cfg_raw = cfg.get("model") + model_cfg: Dict[str, Any] = model_cfg_raw if isinstance(model_cfg_raw, dict) else {} + base_url = str(model_cfg.get("base_url") or "").strip() + if base_url: + return base_url + + config_provider = str(model_cfg.get("provider") or "").strip() + candidate_names: set[str] = set() + for p in filter(None, (provider, config_provider)): + candidate_names.add(p) + if p.lower().startswith("custom:"): + candidate_names.add(p.split(":", 1)[1]) + else: + candidate_names.add(f"custom:{p}") + + providers_cfg = cfg.get("providers") + if isinstance(providers_cfg, dict): + for name in candidate_names: + entry = providers_cfg.get(name) + if isinstance(entry, dict): + bu = str(entry.get("base_url") or "").strip() + if bu: + return bu + + custom_providers = cfg.get("custom_providers") + if isinstance(custom_providers, list): + lowered = {n.lower() for n in candidate_names} + for entry_raw in custom_providers: + if not isinstance(entry_raw, dict): + continue + entry_name = str(entry_raw.get("name") or "").strip() + if entry_name not in candidate_names and entry_name.lower() not in lowered: + continue + bu = str(entry_raw.get("base_url") or "").strip() + if bu: + return bu + + return "" + + +def _should_probe_ollama_vision(provider: str, base_url: str) -> bool: + """True when the active provider likely fronts a local Ollama server.""" + p = (provider or "").strip().lower() + if p == "ollama": + return True + if not base_url: + return False + try: + from agent.model_metadata import detect_local_server_type + + return detect_local_server_type(base_url) == "ollama" + except Exception: + return False + + def _coerce_mode(raw: Any) -> str: """Normalize a config value into one of the valid modes.""" if not isinstance(raw, str): @@ -302,15 +374,33 @@ def _lookup_supports_vision( return override if not provider or not model: return None + caps = None try: from agent.models_dev import get_model_capabilities caps = get_model_capabilities(provider, model) except Exception as exc: # pragma: no cover - defensive logger.debug("image_routing: caps lookup failed for %s:%s — %s", provider, model, exc) - return None - if caps is None: - return None - return bool(caps.supports_vision) + if caps is not None: + return bool(caps.supports_vision) + + base_url = _resolve_inference_base_url(cfg, provider) + if not base_url and (provider or "").strip().lower() == "ollama": + base_url = "http://localhost:11434/v1" + if _should_probe_ollama_vision(provider, base_url): + try: + from agent.model_metadata import query_ollama_supports_vision + + ollama_vision = query_ollama_supports_vision(model, base_url) + if ollama_vision is not None: + return ollama_vision + except Exception as exc: # pragma: no cover - defensive + logger.debug( + "image_routing: ollama vision probe failed for %s:%s — %s", + provider, + model, + exc, + ) + return None def decide_image_input_mode( diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 444ad6525eae..9430a98bfb13 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -478,6 +478,16 @@ def _infer_provider_from_url(base_url: str) -> Optional[str]: return None +def _lmstudio_server_root(base_url: str) -> str: + """Return the LM Studio server root for native ``/api/v1`` endpoints.""" + root = _normalize_base_url(base_url).rstrip("/") + for suffix in ("/api/v1", "/api", "/v1"): + if root.endswith(suffix): + root = root[: -len(suffix)].rstrip("/") + break + return root + + def _is_known_provider_base_url(base_url: str) -> bool: return _infer_provider_from_url(base_url) is not None @@ -549,6 +559,7 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: server_url = normalized if server_url.endswith("/v1"): server_url = server_url[:-3] + lmstudio_url = _lmstudio_server_root(base_url) headers = _auth_headers(api_key) @@ -556,7 +567,7 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: with httpx.Client(timeout=2.0, headers=headers) as client: # LM Studio exposes /api/v1/models — check first (most specific) try: - r = client.get(f"{server_url}/api/v1/models") + r = client.get(f"{lmstudio_url}/api/v1/models") if r.status_code == 200: return "lm-studio" except Exception: @@ -774,7 +785,7 @@ def fetch_endpoint_model_metadata( if is_local_endpoint(normalized): try: if detect_local_server_type(normalized, api_key=api_key) == "lm-studio": - server_url = normalized[:-3].rstrip("/") if normalized.endswith("/v1") else normalized + server_url = _lmstudio_server_root(normalized) response = requests.get( server_url.rstrip("/") + "/api/v1/models", headers=headers, @@ -1188,6 +1199,56 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option return None +def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -> Optional[bool]: + """Return True/False when Ollama ``/api/show`` reports vision support. + + Uses the ``capabilities`` field on Ollama 0.6.0+ and falls back to + ``model_info.*.vision.block_count`` on older servers. Returns None when + the server is unreachable, not Ollama, or the model is unknown. + """ + import httpx + + bare_model = _strip_provider_prefix(model) + if not bare_model or not base_url: + return None + + try: + if detect_local_server_type(base_url, api_key=api_key) != "ollama": + return None + except Exception: + return None + + server_url = base_url.rstrip("/") + if server_url.endswith("/v1"): + server_url = server_url[:-3] + + headers = _auth_headers(api_key) + + try: + with httpx.Client(timeout=3.0, headers=headers) as client: + resp = client.post(f"{server_url}/api/show", json={"name": bare_model}) + if resp.status_code != 200: + return None + data = resp.json() + except Exception: + return None + + caps = data.get("capabilities") + if isinstance(caps, list): + if any(str(cap).lower() == "vision" for cap in caps): + return True + if caps: + return False + + model_info = data.get("model_info") + if isinstance(model_info, dict): + for key in model_info: + if "vision.block_count" in str(key).lower(): + return True + + return None + + def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Optional[int]: """Query an Ollama server's native ``/api/show`` for context length. @@ -1297,6 +1358,7 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> server_url = base_url.rstrip("/") if server_url.endswith("/v1"): server_url = server_url[:-3] + lmstudio_url = _lmstudio_server_root(base_url) headers = _auth_headers(api_key) @@ -1340,7 +1402,7 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> # Use _model_id_matches for fuzzy matching: LM Studio stores models as # "publisher/slug" but users configure only "slug" after "local:" prefix. if server_type == "lm-studio": - resp = client.get(f"{server_url}/api/v1/models") + resp = client.get(f"{lmstudio_url}/api/v1/models") if resp.status_code == 200: data = resp.json() for m in data.get("models", []): diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 319be7255e29..3ec4a40b3929 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -88,12 +88,15 @@ def _find_hermes_md(cwd: Path) -> Optional[Path]: stop_at = _find_git_root(cwd) current = cwd.resolve() - for directory in [current, *current.parents]: + # When there is no git root, only check cwd itself – walking parents + # could pick up a .hermes.md planted in /tmp, /home, etc. + search_dirs = [current, *current.parents] if stop_at else [current] + + for directory in search_dirs: for name in _HERMES_MD_NAMES: candidate = directory / name if candidate.is_file(): return candidate - # Stop walking at the git root (or filesystem root). if stop_at and directory == stop_at: break return None diff --git a/agent/redact.py b/agent/redact.py index 43fe046b4de5..c69003fcf663 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -222,6 +222,28 @@ re.IGNORECASE, ) +# Bare-token credential in a web/transport URL: ``scheme://TOKEN@host``. +# This is the ``git remote set-url origin https://PASSWORD@github.com/...`` +# shape from issue #6396 — a single opaque credential in the userinfo position +# with NO ``user:pass`` colon. It is unambiguously a secret: legitimate +# round-trip URLs (OAuth callbacks, magic links, pre-signed shares — see the +# "Web-URL redaction is intentionally OFF" note in redact_sensitive_text) carry +# their tokens in the QUERY STRING, never in bare userinfo. The colon form +# ``user:pass@`` is deliberately left to pass through (commit "pass web URLs +# through unchanged", #34029) and is NOT matched here — the token class forbids +# ``:``. DB schemes are handled by _DB_CONNSTR_RE above and excluded here. +# +# Guards against false positives: +# - 8+ char floor skips short usernames (git, admin, root, deploy, ubuntu). +# - The token class ``[^\s:@/]`` cannot cross ``/``, so an ``@`` sitting in a +# path or query (e.g. ``?q=user@example.com``) is never treated as userinfo. +_URL_BARE_TOKEN_RE = re.compile( + r"((?:https?|wss?|git|ssh|ftp|ftps|sftp)://)" # scheme + r"([^\s:@/]{8,})" # bare token (no colon/slash/@), 8+ chars + r"(@[^\s]+)", # @host... + re.IGNORECASE, +) + # JWT tokens: header.payload[.signature] — always start with "eyJ" (base64 for "{") # Matches 1-part (header only), 2-part (header.payload), and full 3-part JWTs. _JWT_RE = re.compile( @@ -564,6 +586,16 @@ def _redact_db(m): else: text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text) + # Bare-token userinfo in web/transport URLs: ``scheme://TOKEN@host``. + # The git-remote-with-embedded-password shape from #6396. Only the + # colon-less bare-token form is redacted — ``user:pass@`` and + # query-string tokens are left to pass through (see the web-URL note + # below). See _URL_BARE_TOKEN_RE for the false-positive guards. + text = _URL_BARE_TOKEN_RE.sub( + lambda m: f"{m.group(1)}{_mask_token(m.group(2))}{m.group(3)}", + text, + ) + # JWT tokens (eyJ... — base64-encoded JSON headers) if "eyJ" in text: text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text) @@ -575,7 +607,12 @@ def _redact_db(m): # blanket-redacting param values by name breaks those skills mid-flow. # Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still # caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords - # are still caught by _DB_CONNSTR_RE. + # are still caught by _DB_CONNSTR_RE. The ONE userinfo case still redacted + # is the colon-less bare-token form ``scheme://TOKEN@host`` (#6396, handled + # by _URL_BARE_TOKEN_RE in the ``://`` block above): a bare credential in + # userinfo is never a round-trip workflow token (those live in the query + # string), so masking it can't break a skill. The ``user:pass@`` form is + # left to pass through per #34029. # Form-urlencoded bodies (only triggers on clean k=v&k=v inputs). if "&" in text and "=" in text: diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8a6d3efe9bf2..3182b3ac2389 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig ### How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the `tui_gateway`/dashboard APIs and reuses the agent runtime rather than embedding `hermes --tui`. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. ### Verification diff --git a/apps/desktop/electron/backend-command.cjs b/apps/desktop/electron/backend-command.cjs new file mode 100644 index 000000000000..9ada2cdf034c --- /dev/null +++ b/apps/desktop/electron/backend-command.cjs @@ -0,0 +1,51 @@ +'use strict' + +// Backend subcommand routing for the desktop-managed Hermes process. +// +// The desktop app launches its own headless backend via `hermes serve` — it +// must NEVER depend on or launch the browser `dashboard`. But `serve` is a +// newer subcommand: a runtime that predates it (an older managed install the +// app hasn't updated yet, or an older `hermes` resolved from PATH) only knows +// `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect +// whether the resolved runtime understands `serve` and, only when it does not, +// fall back to the legacy `dashboard --no-open` invocation. Both produce the +// exact same headless gateway; `serve` is just the decoupled name. +// +// These helpers are pure so they can be unit-tested without Electron. + +/** + * Build the canonical headless backend argv (always `serve`). + * @param {string} [profile] optional Hermes profile to pin via `--profile`. + */ +function serveBackendArgs(profile) { + const head = profile ? ['--profile', profile] : [] + return [...head, 'serve', '--host', '127.0.0.1', '--port', '0'] +} + +/** + * Rewrite a resolved backend argv from `serve` to the legacy + * `dashboard --no-open` form, preserving every other argument (incl. a leading + * `-m hermes_cli.main` and any `--profile `). Returns a copy; if there is + * no `serve` token the argv is returned unchanged. + */ +function dashboardFallbackArgs(args) { + const i = args.indexOf('serve') + if (i === -1) return args.slice() + return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)] +} + +/** + * True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers + * the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'` + * specifically so the substring "server" (e.g. "start_server", "web server") + * never produces a false positive. + */ +function sourceDeclaresServe(dashboardPySource) { + return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || '')) +} + +module.exports = { + serveBackendArgs, + dashboardFallbackArgs, + sourceDeclaresServe, +} diff --git a/apps/desktop/electron/backend-command.test.cjs b/apps/desktop/electron/backend-command.test.cjs new file mode 100644 index 000000000000..d483ad2fa5c1 --- /dev/null +++ b/apps/desktop/electron/backend-command.test.cjs @@ -0,0 +1,83 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + serveBackendArgs, + dashboardFallbackArgs, + sourceDeclaresServe, +} = require('./backend-command.cjs') + +test('serveBackendArgs builds a headless serve invocation', () => { + assert.deepEqual(serveBackendArgs(), [ + 'serve', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('serveBackendArgs pins a profile when provided', () => { + assert.deepEqual(serveBackendArgs('worker'), [ + '--profile', + 'worker', + 'serve', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => { + const serve = ['-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0'] + assert.deepEqual(dashboardFallbackArgs(serve), [ + '-m', + 'hermes_cli.main', + 'dashboard', + '--no-open', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => { + const serve = ['-m', 'hermes_cli.main', '--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'] + assert.deepEqual(dashboardFallbackArgs(serve), [ + '-m', + 'hermes_cli.main', + '--profile', + 'worker', + 'dashboard', + '--no-open', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => { + const args = ['-m', 'hermes_cli.main', 'dashboard', '--no-open'] + const out = dashboardFallbackArgs(args) + assert.deepEqual(out, args) + assert.notEqual(out, args, 'should return a copy, not the same reference') +}) + +test('sourceDeclaresServe detects the serve subparser registration', () => { + assert.equal(sourceDeclaresServe('subparsers.add_parser("serve", help="...")'), true) + assert.equal(sourceDeclaresServe("subparsers.add_parser('serve')"), true) + assert.equal(sourceDeclaresServe('subparsers.add_parser(\n "serve",\n)'), true) +}) + +test('sourceDeclaresServe does not false-positive on the substring "server"', () => { + const oldSource = ` + dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard") + from hermes_cli.web_server import start_server # web server + ` + assert.equal(sourceDeclaresServe(oldSource), false) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c873a4bc9156..e800034500b4 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -39,6 +39,7 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') +const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') @@ -791,7 +792,7 @@ let rendererReloadTimes = [] // 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. +// respawning hermes serve backend 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. @@ -1309,7 +1310,7 @@ function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } -function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { +function unwrapWindowsVenvHermesCommand(command, backendArgs) { if (!IS_WINDOWS || !command || isCommandScript(command)) return null const resolved = path.resolve(String(command)) @@ -1319,14 +1320,14 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null const venvRoot = path.dirname(scriptsDir) - const python = getNoConsoleVenvPython(venvRoot) + const python = getVenvPython(venvRoot) if (!fileExists(python)) return null const root = path.dirname(venvRoot) return { - label: `existing Hermes no-console Python at ${python}`, + label: `existing Hermes Python at ${python}`, command: python, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, @@ -1334,11 +1335,72 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { venvRoot }), kind: 'python', - readyFile: true, + // Surfaced so backendSupportsServe() can read this runtime's source for the + // `serve` capability check instead of falling back to a heavyweight probe. + root, shell: false } } +// Does the resolved runtime understand the `serve` subcommand? The desktop +// spawns `hermes serve`; runtimes older than serve only have `dashboard`. We +// detect support so getBackendArgsForRuntime() can route old runtimes through +// the legacy `dashboard --no-open` form instead of crashing on an unknown +// subcommand (would brick every user mid-upgrade — #54568 follow-up). +// +// Fast path: read the runtime's own dashboard.py (instant, covers managed +// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once +// (covers a bare `hermes` resolved from PATH with no known source root). Result +// is cached per resolved runtime so we probe at most once per backend. +const _serveSupportCache = new Map() +function backendSupportsServe(backend) { + if (!backend || !backend.command) return true + const key = `${backend.command}::${backend.root || ''}` + if (_serveSupportCache.has(key)) return _serveSupportCache.get(key) + + let supported = null + if (backend.root) { + try { + const src = fs.readFileSync( + path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), + 'utf8' + ) + supported = sourceDeclaresServe(src) + } catch { + supported = null // source unreadable — fall through to the probe + } + } + + if (supported === null) { + try { + const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : [] + execFileSync(backend.command, [...prefix, 'serve', '--help'], { + cwd: backend.root || undefined, + env: { ...process.env, HERMES_HOME, ...(backend.env || {}) }, + timeout: 15000, + stdio: 'ignore', + windowsHide: true + }) + supported = true + } catch { + supported = false + } + } + + _serveSupportCache.set(key, supported) + rememberLog( + `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` + ) + return supported +} + +// Given a resolved backend whose args target `serve`, return the args the +// runtime actually understands: unchanged when `serve` is supported, or +// rewritten to `dashboard --no-open` for older runtimes. +function getBackendArgsForRuntime(backend) { + return backendSupportsServe(backend) ? backend.args : dashboardFallbackArgs(backend.args) +} + function normalizeExecutablePathForCompare(commandPath) { if (!commandPath) return null @@ -1559,62 +1621,26 @@ 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) - - // The venv's ``Scripts\pythonw.exe`` is a uv launcher shim that re-execs the - // base console ``python.exe``, allocating a conhost/Windows Terminal window - // that CREATE_NO_WINDOW can't suppress. Use the base ``pythonw.exe`` directly; - // callers put the venv site-packages on PYTHONPATH so imports still resolve. - const baseHome = readVenvHome(venvRoot) - if (baseHome) { - const basePythonw = path.join(baseHome, 'pythonw.exe') - if (fileExists(basePythonw)) return basePythonw - } - - return path.join(venvRoot, 'Scripts', 'pythonw.exe') -} - -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 -} +// Windows console-window flashes are governed by the *parent's* console, not by +// each child spawn. A GUI-subsystem parent (pythonw.exe) has no console, so every +// console-subsystem child it spawns (git, gh, cmd, ...) must allocate its own — +// which flashes a window. A console-subsystem parent (python.exe) instead owns a +// single console that all of its children inherit, so none of them flash. +// +// Note this change adds no new creationflag: the backend spawn is ALREADY wrapped +// in hiddenWindowsChildOptions() (windowsHide: true), but that setting is INERT +// against pythonw.exe — a GUI-subsystem process has no console for it to act on. +// Switching the backend to the venv's console python.exe is what makes the +// existing wrapper load-bearing: with windowsHide the process comes up owning a +// *windowless* console (verified at runtime — it has an attachable console whose +// window handle is NULL), and its children inherit that one windowless console +// instead of each allocating a visible one. +// +// This makes "no flashing windows" a property of the one backend launch rather +// than a flag that has to be remembered at every descendant spawn site. Restoring +// console python also restores stdout, so the backend announces its port on the +// normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is +// needed. function getVenvSitePackagesEntries(venvRoot) { const entries = [] @@ -2372,14 +2398,14 @@ async function applyUpdatesPosixInApp() { PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } - // `hermes update` reaps stale `hermes dashboard` backends (a code update + // `hermes update` reaps stale `hermes serve` backends (a code update // leaves the running process serving old Python against the freshly-updated // JS bundle). But OUR backend is one of those processes, and killing it // mid-update produces the boot→kill→crash loop in #37532 — the desktop // already restarts its own backend via the rebuild+relaunch below, so the // reap must spare it. Hand the live backend's PID to the update process; // _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes - // it while still reaping any genuinely-orphaned dashboards. (#37532) + // it while still reaping any genuinely-orphaned backends. (#37532) // Exclude every desktop-managed backend (primary + all pool profiles) from // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated // list (a single int still parses for back-compat). @@ -2830,19 +2856,19 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, dashboardArgs, options = {}) { +function createPythonBackend(root, label, backendArgs, options = {}) { const python = findPythonForRoot(root) if (!python) return null const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) - const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python) + const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label, command, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], @@ -2851,22 +2877,22 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { root, bootstrap: Boolean(options.bootstrap), shell: false - }) + } } // createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the // canonical install location shared with the CLI installer. The venv at // VENV_ROOT may not exist yet on first run; bootstrap=true tells // ensureRuntime() to create / refresh it before launch. -function createActiveBackend(dashboardArgs) { +function createActiveBackend(backendArgs) { const venvPython = getVenvPython(VENV_ROOT) - const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython()) + const command = fileExists(venvPython) ? venvPython : findSystemPython() - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], @@ -2875,15 +2901,15 @@ function createActiveBackend(dashboardArgs) { root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false - }) + } } -function resolveHermesBackend(dashboardArgs) { +function resolveHermesBackend(backendArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) if (overrideRoot && isHermesSourceRoot(overrideRoot)) { - const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs) + const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) if (backend) return backend } @@ -2892,7 +2918,7 @@ function resolveHermesBackend(dashboardArgs) { // installed `hermes` on PATH so local Python edits are actually exercised. // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { - const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs) + const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) if (backend) return backend } @@ -2903,7 +2929,7 @@ function resolveHermesBackend(dashboardArgs) { // to spawning hermes. Updates flow through the in-app update path // (applyUpdates -> git pull) or `hermes update` from the CLI. if (isBootstrapComplete()) { - return createActiveBackend(dashboardArgs) + return createActiveBackend(backendArgs) } // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from @@ -2936,7 +2962,7 @@ function resolveHermesBackend(dashboardArgs) { } if (hermesCommand) { - const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) + const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) if (unwrapped) { return unwrapped } @@ -2951,10 +2977,10 @@ function resolveHermesBackend(dashboardArgs) { const shellForProbe = isCommandScript(hermesCommand) if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( - unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || { + unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, - args: dashboardArgs, + args: backendArgs, bootstrap: false, env: {}, kind: 'command', @@ -2982,15 +3008,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 applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `installed hermes_cli module via ${python}`, - command: toNoConsolePython(python), - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + command: python, + args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: {}, shell: false - }) + } } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -3009,7 +3035,7 @@ function resolveHermesBackend(dashboardArgs) { kind: 'bootstrap-needed', label: 'Hermes Agent not installed yet; bootstrap required', command: null, - args: dashboardArgs, + args: backendArgs, bootstrap: true, env: {}, shell: false, @@ -3024,7 +3050,7 @@ function resolveHermesBackend(dashboardArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't @@ -3166,7 +3192,7 @@ async function ensureRuntime(backend) { ) } - backend.command = getNoConsoleVenvPython(VENV_ROOT) + backend.command = getVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', @@ -3175,7 +3201,7 @@ async function ensureRuntime(backend) { running: true, error: null }) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } function fetchJson(url, token, options = {}) { @@ -5242,8 +5268,10 @@ async function spawnPoolBackend(profile, entry) { // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override // step 3 in hermes_cli/main.py), so the child re-homes to this profile. // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. - const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0'] + const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) + // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. + backend.args = getBackendArgsForRuntime(backend) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null @@ -5459,7 +5487,7 @@ async function startHermes() { const token = crypto.randomBytes(32).toString('base64url') // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. - const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] + const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0'] // Pin the desktop's chosen profile via the global --profile flag. This is // deterministic (it wins over the sticky ~/.hermes/active_profile file) and // resolves HERMES_HOME the same way `hermes -p ` does on the CLI. An @@ -5467,10 +5495,12 @@ async function startHermes() { // unaffected. const activeProfile = readActiveDesktopProfile() if (activeProfile) { - dashboardArgs.unshift('--profile', activeProfile) + backendArgs.unshift('--profile', activeProfile) } await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) + // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. + backend.args = getBackendArgsForRuntime(backend) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 0a91272fac46..473fd0b2e0b6 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -38,35 +38,40 @@ test('desktop background child processes opt into hidden Windows consoles', () = 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 unwrapWindowsVenvHermesCommand\(command, backendArgs\)/) assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) - assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/) + assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/) }) -test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => { +test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { const source = readElectronFile('main.cjs') - const body = source.slice( - source.indexOf('function getNoConsoleVenvPython(venvRoot)'), - source.indexOf('function getVenvSitePackagesEntries(venvRoot)') + + // The flash fix is structural: the backend runs as a console-subsystem + // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it + // owns ONE windowless console that every descendant spawn inherits. Launching + // it as GUI-subsystem pythonw.exe is what made each child allocate (and flash) + // its own console, so the backend command must never be pythonw. + assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe') + assert.doesNotMatch( + source, + /function getNoConsoleVenvPython\b/, + 'pythonw-conversion helper should be gone; console python is launched directly' + ) + assert.doesNotMatch( + source, + /function applyWindowsNoConsoleSpawnHints\b/, + 'pythonw spawn-hint rewriter should be gone' ) - // The venv Scripts\pythonw.exe re-execs a console python.exe (flashes a - // conhost); the base pythonw must be resolved first so it never runs. - const baseIdx = body.indexOf('basePythonw') - const shimIdx = body.indexOf("'Scripts', 'pythonw.exe'") - assert.notEqual(baseIdx, -1, 'base pythonw resolution missing') - assert.notEqual(shimIdx, -1, 'venv shim fallback missing') - assert.ok(baseIdx < shimIdx, 'base pythonw must be preferred before the venv Scripts shim') + // Console python restores stdout, so the port is announced on the normal + // HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set. + assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path') + + // Both desktop backend launches must still go through hiddenWindowsChildOptions + // so the single backend console is created windowless. + requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) + requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) }) test('intentional or interactive desktop child processes stay documented', () => { @@ -84,5 +89,5 @@ test('bootstrap PowerShell runner hides Windows console children', () => { const source = readElectronFile('bootstrap-runner.cjs') assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) - requireHiddenChildOptions(source, 'spawn(ps, fullArgs') + requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b4e3328402cf..4bf4eaade965 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -73,6 +73,7 @@ "@tanstack/react-virtual": "^3.13.24", "@vscode/codicons": "^0.0.45", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 8f6c2349f836..fd13758599b9 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -19,7 +19,7 @@ import { type SubagentStreamEntry } from '@/store/subagents' -import { OverlayView } from '../overlays/overlay-view' +import { Panel, PanelEmpty, PanelHeader } from '../overlays/panel' // Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the // same visual vocabulary as the chat tool blocks. @@ -86,18 +86,16 @@ export function AgentsView({ onClose }: AgentsViewProps) { const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession]) return ( - -
-

{t.agents.title}

-

{t.agents.subtitle}

-
- -
+ + {tree.length === 0 ? ( + + ) : ( + <> + + + + )} + ) } diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7bef1e827674..364ee6d3b83d 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -4,7 +4,7 @@ import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons' +import { AudioLines, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -39,6 +39,7 @@ interface ConversationProps { } export function ComposerControls({ + autoSpeak, busy, busyAction, canSteer, @@ -50,8 +51,10 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer + onSteer, + onToggleAutoSpeak }: { + autoSpeak: boolean busy: boolean busyAction: 'queue' | 'stop' canSteer: boolean @@ -64,6 +67,7 @@ export function ComposerControls({ voiceStatus: VoiceStatus onDictate: () => void onSteer: () => void + onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer @@ -105,6 +109,7 @@ export function ComposerControls({ ) : ( )} + {showVoicePrimary ? ( + + ) +} + function DictationButton({ disabled, state, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts new file mode 100644 index 000000000000..c3268bc9cbd3 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -0,0 +1,79 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' + +import { playSpeechText } from '@/lib/voice-playback' +import { notifyError } from '@/store/notifications' +import { $messages } from '@/store/session' +import { $voicePlayback } from '@/store/voice-playback' +import { $autoSpeakReplies } from '@/store/voice-prefs' + +interface AutoSpeakReply { + id: string + pending: boolean + text: string +} + +interface UseAutoSpeakReplies { + conversationActive: boolean + failureLabel: string + /** Mark the current last reply spoken — shared dedupe with the conversation consumer. */ + markSpoken: () => void + /** Latest completed assistant reply, or null; `pending` true while still streaming. */ + pendingReply: () => AutoSpeakReply | null + /** Re-arm on session switch so opening a chat never reads its existing last reply. */ + sessionId: string | null | undefined +} + +/** + * Pure-TTS auto-speak: when `voice.auto_tts` is on, read each completed assistant + * turn aloud — no dictation, no conversation loop. Stays off while a full voice + * conversation runs (it speaks replies itself) and never overlaps clips: a reply + * landing mid-playback is held and spoken on the playback-idle edge. Always reads + * the latest reply, so a backlog collapses to the newest. + */ +export function useAutoSpeakReplies({ + conversationActive, + failureLabel, + markSpoken, + pendingReply, + sessionId +}: UseAutoSpeakReplies) { + const enabled = useStore($autoSpeakReplies) + const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply }) + latest.current = { conversationActive, failureLabel, markSpoken, pendingReply } + + useEffect(() => { + if (!enabled) { + return undefined + } + + // Don't read whatever reply already sits at the bottom when the toggle flips + // on (or a chat opens) — consume it so only later replies are spoken. + latest.current.markSpoken() + + const speakLatest = () => { + const { conversationActive, failureLabel, markSpoken, pendingReply } = latest.current + + if (conversationActive || $voicePlayback.get().status !== 'idle') { + return + } + + const reply = pendingReply() + + if (!reply || reply.pending) { + return + } + + markSpoken() + void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error => + notifyError(error, failureLabel) + ) + } + + // Re-check on a reply completing ($messages) and on the prior clip ending + // ($voicePlayback → idle), which frees us to read the next held reply. + const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)] + + return () => stops.forEach(f => f()) + }, [enabled, sessionId]) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 379b6732a883..796e773153e2 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -60,13 +60,14 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' -import { notify } from '@/store/notifications' +import { notify, notifyError } from '@/store/notifications' import { $previewStatusBySession } from '@/store/preview-status' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' +import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' @@ -88,6 +89,7 @@ import { } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useAutoSpeakReplies } from './hooks/use-auto-speak-replies' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' @@ -230,6 +232,7 @@ export function ChatBar({ const statusItemsBySession = useStore($statusItemsBySession) const previewStatusBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) + const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must // not interrupt it — there's nothing actively running to stop, and stopping // would discard a question the user may want to come back to. The blocking @@ -2021,6 +2024,20 @@ export function ChatBar({ useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) + const handleToggleAutoSpeak = useCallback(() => { + void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error => + notifyError(error, t.settings.config.autosaveFailed) + ) + }, [t]) + + useAutoSpeakReplies({ + conversationActive: voiceConversationActive, + failureLabel: t.assistant.thread.readAloudFailed, + markSpoken: consumePendingResponse, + pendingReply: pendingResponse, + sessionId + }) + const contextMenu = ( 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 bc54b92ffe94..68962cb7295f 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 @@ -1,10 +1,9 @@ -import { Fragment, memo, type ReactNode, useState } from 'react' +import { Fragment, memo, type ReactNode } from 'react' +import { openAgentTerminal } from '@/app/right-sidebar/terminal/terminals' import { StatusRow } from '@/components/chat/status-row' -import { TerminalOutput } from '@/components/chat/terminal-output' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -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' @@ -82,7 +81,6 @@ interface StatusItemRowProps { export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) { const { t } = useI18n() const s = t.statusStack - const [outputOpen, setOutputOpen] = useState(false) const failed = item.state === 'failed' const running = item.state === 'running' @@ -94,8 +92,10 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp : null const canOpen = item.type === 'subagent' && !!onOpen - const hasOutput = item.type === 'background' && !!item.output - const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined + + // Background rows link to their read-only terminal tab; subagents open their session. + const onActivate = + item.type === 'background' ? () => openAgentTerminal(item.id, item.title) : canOpen ? onOpen : undefined return ( @@ -146,9 +146,7 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp {s.exit(item.exitCode)} )} - {hasOutput && } - {hasOutput && outputOpen && } ) }) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index ecc13808413a..a8afdd128306 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -5,6 +5,7 @@ import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' +import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs' import { addComposerAttachment, type ComposerAttachment, @@ -262,7 +263,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const pickContextPaths = useCallback( async (kind: 'file' | 'folder') => { - const paths = await window.hermesDesktop?.selectPaths({ + const paths = await selectDesktopPaths({ title: kind === 'file' ? 'Add files as context' : 'Add folders as context', defaultPath: currentCwd || undefined, directories: kind === 'folder' @@ -347,7 +348,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway attachToMain(baseAttachment) try { - const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + const previewUrl = await readDesktopFileDataUrl(filePath) if (previewUrl) { addComposerAttachment({ ...baseAttachment, previewUrl }) @@ -395,7 +396,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway ) const pickImages = useCallback(async () => { - const paths = await window.hermesDesktop?.selectPaths({ + const paths = await selectDesktopPaths({ title: copy.attachImages, defaultPath: currentCwd || undefined, filters: [ diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b61df2337b71..a5216210a799 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -45,7 +45,7 @@ import { $sessions, sessionPinId } from '@/store/session' -import { isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' @@ -342,8 +342,9 @@ export function ChatView({ const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) // Hide the composer in the exhausted error state too: there's no live runtime - // to send to until a retry rebinds one. - const showChatBar = !loadingSession && !resumeExhausted + // to send to until a retry rebinds one. Watch windows are pure spectators of a + // subagent run driven elsewhere — no composer, transcript is read-only. + const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow() const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const modelOptionsQuery = useQuery({ diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 19665341f3d6..ca38d65908ba 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1149,7 +1149,8 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 + const showSessionSections = + showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0 // Each reorderable list reports its OWN new id order; persisting is a direct, // typed write — no id-prefix sniffing to figure out which level moved. @@ -1537,7 +1538,7 @@ export function ChatSidebar({ )} - {contentVisible && !showSessionSections &&
} + {contentVisible && !showSessionSections && } {contentVisible && (
@@ -1618,6 +1619,29 @@ function SidebarSessionSkeletons() { ) } +function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) { + const { t } = useI18n() + const s = t.sidebar + + return ( +
+
+ +

{s.noSessions}

+ +
+
+ ) +} + function SidebarPinnedEmptyState() { const { t } = useI18n() diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 612305b1479b..100ad8001e44 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -200,7 +200,7 @@ export function ProfileRail() { }, [createRequest]) return ( -
+
{/* One button toggles default ↔ all: home face when scoped to a profile, layers face when showing everything. Pinned left like Manage is right. Hidden until a second profile exists. */} diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx index 16cbb04983d6..dcd9f067f438 100644 --- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx +++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx @@ -87,21 +87,25 @@ export function ProjectDialog() { } const pickFolder = async () => { - const dir = await pickProjectFolder() + try { + const dir = await pickProjectFolder() - if (!dir) { - return - } + if (!dir) { + return + } - const projectId = state?.projectId + const projectId = state?.projectId - if (mode === 'add-folder' && projectId) { - await runSubmit(() => addProjectFolder(projectId, dir)) + if (mode === 'add-folder' && projectId) { + await runSubmit(() => addProjectFolder(projectId, dir)) - return - } + return + } - setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir])) + setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir])) + } catch (err) { + notifyError(err, p.createFailed) + } } const submit = async () => { @@ -145,7 +149,10 @@ export function ProjectDialog() { return ( - + event.preventDefault()} + > {title} {mode === 'create' && {p.createDesc}} diff --git a/apps/desktop/src/app/chat/sidebar/projects/model.ts b/apps/desktop/src/app/chat/sidebar/projects/model.ts index e5931f0bcb87..1c17c676e0c6 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/model.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/model.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' +import { desktopGit } from '@/lib/desktop-git' import { mapPool } from '@/lib/pool' import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout' import { $worktreeRefreshToken } from '@/store/projects' @@ -88,7 +89,7 @@ export function useRepoWorktreeMap( const refreshToken = useStore($worktreeRefreshToken) useEffect(() => { - const git = window.hermesDesktop?.git + const git = desktopGit() if (!enabled || !repoPaths.length || !git?.worktreeList) { setMap({}) diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 9eacc0f41ee7..f6f2ed0324a6 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -9,7 +9,16 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' -import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons' +import { + Activity, + AlertCircle, + BarChart3, + Bookmark, + BookmarkFilled, + Download, + MessageCircle, + Trash2 +} from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' @@ -263,7 +272,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on {SECTIONS.map(value => ( setSection(value)} @@ -361,7 +370,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on /> ) : (
-
+
{status ? (
@@ -406,7 +415,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on )}
-
+
{cc.recentLogs} @@ -503,7 +512,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp )} -
+
-
+
({ diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 342f53ee042e..eb298bde1751 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -14,7 +14,6 @@ import { DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { SearchField } from '@/components/ui/search-field' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { @@ -30,14 +29,28 @@ import { updateCronJob } from '@/hermes' import { type Translations, useI18n } from '@/i18n' -import { AlertTriangle, Clock } from '@/lib/icons' -import { cn } from '@/lib/utils' +import { AlertTriangle } from '@/lib/icons' import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' -import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' -import { OverlayView } from '../overlays/overlay-view' +import { + Panel, + PanelAction, + PanelAddButton, + PanelBlock, + PanelBody, + PanelDetail, + PanelEmpty, + PanelHeader, + PanelList, + PanelListRow, + PanelMeta, + PanelPill, + type PanelPillTone, + PanelRowMenu, + PanelSectionLabel +} from '../overlays/panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { jobState, jobTitle, STATE_DOT } from './job-state' @@ -56,7 +69,7 @@ const SCHEDULE_OPTIONS: ReadonlyArray = [ { value: 'custom' } ] -const STATE_TONE: Record = { +const STATE_TONE: Record = { enabled: 'good', scheduled: 'good', running: 'good', @@ -66,13 +79,6 @@ const STATE_TONE: Record = { completed: 'muted' } -const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = { - good: 'bg-primary/10 text-primary', - muted: 'bg-muted text-muted-foreground', - warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', - bad: 'bg-destructive/10 text-destructive' -} - const asText = (value: unknown): string => (typeof value === 'string' ? value : '') const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value) @@ -321,7 +327,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt pendingScrollRef.current = null requestAnimationFrame(() => { - document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' }) + document.querySelector(`[data-panel-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' }) }) }, [selectedJob]) @@ -406,60 +412,66 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt } return ( - + {loading && jobs.length === 0 ? ( + ) : totalCount === 0 ? ( + setEditor({ mode: 'create' })} size="sm"> + {c.newCron} + + } + description={c.emptyDescNew} + icon="watch" + title={c.emptyTitleNew} + /> ) : ( - - - setEditor({ mode: 'create' })} /> - {totalCount > 0 && ( - - )} - {visibleJobs.map(job => ( - setSelectedJobId(job.id)} - /> - ))} - {visibleJobs.length === 0 && ( -

- {totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} -

- )} -
+ <> + + + + {visibleJobs.map(job => ( + setEditor({ mode: 'edit', job }) }, + { icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' } + ]} + /> + } + onSelect={() => setSelectedJobId(job.id)} + /> + ))} + {visibleJobs.length === 0 && ( +

{c.emptyTitleSearch}

+ )} + setEditor({ mode: 'create' })} /> +
- {selectedJob ? ( setPendingDelete(selectedJob)} - onEdit={() => setEditor({ mode: 'edit', job: selectedJob })} onOpenSession={onOpenSession} onPauseResume={() => void handlePauseResume(selectedJob)} onTrigger={() => void handleTrigger(selectedJob)} /> ) : ( -
-
- -

{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}

-
-
+ )} -
-
+ + )} setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> @@ -488,42 +500,32 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
- + ) } function CronJobListRow({ active, - c, job, + menu, onSelect }: { active: boolean - c: Translations['cron'] job: CronJob + menu?: React.ReactNode onSelect: () => void }) { const state = jobState(job) return ( - + ) } @@ -531,8 +533,6 @@ function CronJobDetail({ busy, c, job, - onDelete, - onEdit, onOpenSession, onPauseResume, onTrigger @@ -540,8 +540,6 @@ function CronJobDetail({ busy: boolean c: Translations['cron'] job: CronJob - onDelete: () => void - onEdit: () => void onOpenSession?: (sessionId: string) => void onPauseResume: () => void onTrigger: () => void @@ -552,69 +550,49 @@ function CronJobDetail({ const prompt = jobPrompt(job) return ( -
-
-
-
-
-
-
-

{jobTitle(job)}

- {c.states[state] ?? state} - {deliver && deliver !== DEFAULT_DELIVER && ( - {c.deliveryLabels[deliver] ?? deliver} - )} -
-
- - - {jobScheduleDisplay(job)} - - - {c.last} {formatTime(job.last_run_at)} - - - {c.next} {formatTime(job.next_run_at)} - -
-
-
- - - - -
-
+ +
+
+
+

{jobTitle(job)}

+ {c.states[state] ?? state} +
+
+ + {isPaused ? c.resumeTitle : c.pauseTitle} + + + {c.triggerNow} + +
+
- {prompt &&

{prompt}

} - {job.last_error && ( -

- - {job.last_error} -

- )} -
+ - -
-
-
+ {job.last_error ? ( +
+ + {job.last_error} +
+ ) : null} + + + {prompt ? ( +
+ {c.promptLabel} + {prompt} +
+ ) : null} + + + ) } @@ -685,10 +663,10 @@ function CronJobRuns({ return (
-
+ {c.runHistory} {runs && runs.length > 0 ? ` · ${runs.length}` : ''} -
+ {runs === null ? (
@@ -699,13 +677,13 @@ function CronJobRuns({
{runs.map(run => ( @@ -716,16 +694,6 @@ function CronJobRuns({ ) } -function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { - return ( - - {children} - - ) -} - function CronEditorDialog({ editor, onClose, diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 9df44d628cec..fece8887401d 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla import { Pane, PaneMain } from '@/components/pane-shell' import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { useMediaQuery } from '@/hooks/use-media-query' +import { isFocusWithin } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import { useSkinCommand } from '@/themes/use-skin-command' @@ -124,9 +125,12 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay' import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' import { RightSidebarPane } from './right-sidebar' import { FileActionDialogs } from './right-sidebar/file-actions' +import { RemoteFolderPicker } from './right-sidebar/files/remote-picker' import { ReviewPane } from './right-sidebar/review' import { $terminalTakeover } from './right-sidebar/store' -import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' +import { TerminalPaneChrome } from './right-sidebar/terminal/chrome' +import { PersistentTerminal } from './right-sidebar/terminal/persistent' +import { closeActiveTerminal } from './right-sidebar/terminal/terminals' import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' import { SessionPickerOverlay } from './session-picker-overlay' import { SessionSwitcher } from './session-switcher' @@ -387,11 +391,25 @@ export function DesktopController() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (!$filePreviewTarget.get() && !$previewTarget.get()) { + if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'w' || (!event.metaKey && !event.ctrlKey)) { return } - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') { + // Terminal focused: ⌘W closes the active terminal. Ctrl+W is left untouched + // for the shell's werase, and nothing else may steal ⌘/Ctrl+W from a + // focused terminal (so it never closes a preview tab out from under it). + if (isFocusWithin('[data-terminal]')) { + if (event.metaKey && !event.ctrlKey) { + event.preventDefault() + event.stopPropagation() + closeActiveTerminal() + } + + return + } + + // Otherwise ⌘/Ctrl+W closes the active preview tab when one is open. + if ($filePreviewTarget.get() || $previewTarget.get()) { event.preventDefault() event.stopPropagation() closeActiveRightRailTab() @@ -580,7 +598,7 @@ export function DesktopController() { } }, []) - const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) const updateActiveSessionRuntimeInfo = useCallback( (info: { branch?: string; cwd?: string }) => { @@ -1060,7 +1078,6 @@ export function DesktopController() { commandCenterOpen, extraLeftItems: statusbarItemGroups.flat.left, extraRightItems: statusbarItemGroups.flat.right, - gatewayLogLines, gatewayState, inferenceStatus, openAgents, @@ -1095,11 +1112,13 @@ export function DesktopController() { /> ) - // One PTY-backed terminal mounted forever; placeholders decide - // where it shows. Lives in main's stacking context (not the root overlay layer) - // so pane resize handles still paint above it. Toggling never rebuilds the shell. + // The persistent xterm layer (one host per terminal tab), CSS-overlaid onto the + // pane's . Lives in main's stacking context (not the root overlay + // layer) so pane resize handles still paint above it. Terminals own their state + // (incl. a snapshotted cwd) independent of the session, so switching sessions + // never rebuilds or closes them; toggling the pane never rebuilds the shells. const mainOverlays = ( - + ) const overlays = ( @@ -1127,6 +1146,7 @@ export function DesktopController() { + {settingsOpen && ( @@ -1329,7 +1349,7 @@ export function DesktopController() { terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)' )} > - +
) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 1db1c2aaa0d9..26a4e2ce7c8e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -1,10 +1,10 @@ +import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { useEffect, useRef } from 'react' import type { HermesConnection } from '@/global' import { HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' import { desktopDefaultCwd } from '@/lib/desktop-fs' -import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $desktopBoot, applyDesktopBootProgress, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 29b6cbd80c8b..d6c9ab0e029b 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -1,8 +1,8 @@ +import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef } from 'react' import type { HermesGateway } from '@/hermes' -import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway' import { $activeGatewayProfile } from '@/store/profile' import { $gatewayState, setConnection } from '@/store/session' diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 80370f2488b1..379c719ad036 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' +import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals' import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' import { matchesQuery } from '@/hooks/use-media-query' import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' @@ -164,6 +165,17 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.toggleReview': toggleReview, 'view.showFiles': showFiles, 'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()), + // Create first so the pane's open-effect ensure sees a non-empty set and + // doesn't also spawn one — net effect is exactly one fresh terminal. + 'view.newTerminal': () => { + createTerminal() + setTerminalTakeover(true) + }, + // Switch / close only act while the pane is open (no focus-scoping here, so + // this stands in for "terminal is showing"). + 'view.nextTerminal': () => $terminalTakeover.get() && cycleTerminal(1), + 'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1), + 'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(), 'view.flipPanes': togglePanesFlipped, 'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'), diff --git a/apps/desktop/src/app/overlays/overlay-chrome.tsx b/apps/desktop/src/app/overlays/overlay-chrome.tsx index 23a57da4eb51..5a28e4fb80ec 100644 --- a/apps/desktop/src/app/overlays/overlay-chrome.tsx +++ b/apps/desktop/src/app/overlays/overlay-chrome.tsx @@ -1,26 +1,11 @@ -import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react' +import type { ButtonHTMLAttributes, ReactNode } from 'react' import { cn } from '@/lib/utils' -export const overlayCardClass = - 'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]' - -interface OverlayCardProps extends ComponentProps<'div'> { - children: ReactNode -} - interface OverlayActionButtonProps extends ButtonHTMLAttributes { tone?: 'default' | 'danger' | 'subtle' } -export function OverlayCard({ children, className, ...props }: OverlayCardProps) { - return ( -
- {children} -
- ) -} - export function OverlayActionButton({ children, className, diff --git a/apps/desktop/src/app/overlays/overlay-search-input.tsx b/apps/desktop/src/app/overlays/overlay-search-input.tsx deleted file mode 100644 index 4f82b0918bcb..000000000000 --- a/apps/desktop/src/app/overlays/overlay-search-input.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { RefObject } from 'react' - -import { SearchField } from '@/components/ui/search-field' - -interface OverlaySearchInputProps { - containerClassName?: string - inputRef?: RefObject - loading?: boolean - onChange: (value: string) => void - placeholder: string - value: string -} - -// Borderless underline search — matches the tools/skills page (PageSearchShell). -export function OverlaySearchInput({ - containerClassName, - inputRef, - loading = false, - onChange, - placeholder, - value -}: OverlaySearchInputProps) { - return ( - - ) -} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index fd562b40e283..6b95e0e48306 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -50,9 +48,10 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) { return (
+ ) +} + +export interface PanelMenuItem { + disabled?: boolean + icon?: string + label: string + onSelect: () => void + tone?: 'danger' | 'default' +} + +// Per-row "⋮" actions menu — mirrors the sidebar session row's settled pattern +// (size-5 ghost trigger + kebab-vertical codicon + w-40 content). Hidden until +// the row is hovered/focused (or the menu is open). Returns null with no items +// (e.g. the default profile, which can't be renamed/deleted). +export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuItem[]; label?: string }) { + if (items.length === 0) { + return null + } + + return ( + + + + + + {items.map(item => ( + + {item.icon ? : null} + {item.label} + + ))} + + + ) +} + +// Scrolling detail region. Fills the column (no right rail here, unlike the +// trace inspector), so the content stretches the full available width. +export function PanelDetail({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+
{children}
+
+ ) +} + +interface PanelEmptyProps { + action?: ReactNode + description?: ReactNode + // Codicon glyph name (e.g. 'hubot', 'warning', 'loading~spin'). + icon?: string + title?: ReactNode +} + +export function PanelEmpty({ action, description, icon = 'inbox', title }: PanelEmptyProps) { + return ( +
+
+ + {title ?

{title}

: null} + {description ? ( +

{description}

+ ) : null} + {action ?
{action}
: null} +
+
+ ) +} + +export function PanelSectionLabel({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +// Inspector-style key/value grid (mirrors the trace span inspector's
). +export interface PanelMetaRow { + label: ReactNode + value: ReactNode +} + +export function PanelMeta({ className, rows }: { className?: string; rows: PanelMetaRow[] }) { + return ( +
+ {rows.map((row, i) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ) +} + +// Monospace content block (job prompt, etc.) — mirrors the inspector's +// input/output
 blocks: subtle bg, no border.
+export function PanelBlock({ children, className }: { children: ReactNode; className?: string }) {
+  return (
+    
+      {children}
+    
+ ) +} + +export type PanelPillTone = 'bad' | 'good' | 'muted' | 'warn' + +const PILL_TONE: Record = { + bad: 'bg-destructive/10 text-destructive', + good: 'bg-primary/10 text-primary', + muted: 'bg-foreground/10 text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300' +} + +export function PanelPill({ children, tone = 'muted' }: { children: ReactNode; tone?: PanelPillTone }) { + return ( + + {children} + + ) +} + +// Self-describing centered "+" that sits as the LAST item in a PanelList. The +// label rides aria/title only — no visible text. +export function PanelAddButton({ + icon = 'add', + label, + onClick +}: { + icon?: string + label: string + onClick: () => void +}) { + return ( + + ) +} + +// Visible ghost action for a detail header (cron pause/resume/trigger, …). +export function PanelAction({ + children, + disabled, + icon, + onClick +}: { + children: ReactNode + disabled?: boolean + icon: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index c66bfe8e3625..3e44f7fd9127 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -1,8 +1,10 @@ +import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { Dialog, DialogContent, @@ -18,21 +20,34 @@ import { createProfile, deleteProfile, getProfiles, - getProfileSetupCommand, getProfileSoul, type ProfileInfo, renameProfile, updateProfileSoul } from '@/hermes' import { useI18n } from '@/i18n' -import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons' +import { AlertTriangle, Save } from '@/lib/icons' +import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color' import { slug } from '@/lib/sanitize' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' +import { $profileColors } from '@/store/profile' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' -import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' -import { OverlayView } from '../overlays/overlay-view' +import { + Panel, + PanelAddButton, + PanelBody, + PanelDetail, + PanelEmpty, + PanelHeader, + PanelList, + PanelListRow, + PanelMeta, + PanelPill, + PanelRowMenu, + PanelSectionLabel +} from '../overlays/panel' const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ @@ -49,7 +64,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { const p = t.profiles const [profiles, setProfiles] = useState(null) const [selectedName, setSelectedName] = useState(null) + const [query, setQuery] = useState('') const [createOpen, setCreateOpen] = useState(false) + const [pendingRename, setPendingRename] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const [deleting, setDeleting] = useState(false) @@ -83,6 +100,18 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null }, [profiles, selectedName]) + const visibleProfiles = useMemo(() => { + const q = query.trim().toLowerCase() + + if (!profiles || !q) { + return profiles ?? [] + } + + return profiles.filter( + profile => profile.name.toLowerCase().includes(q) || (profile.model ?? '').toLowerCase().includes(q) + ) + }, [profiles, query]) + const handleCreate = useCallback( async (name: string, cloneFrom: null | string) => { const trimmed = name.trim() @@ -140,46 +169,79 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { }, [p, pendingDelete, refresh]) return ( - + {!profiles ? ( + ) : profiles.length === 0 ? ( + setCreateOpen(true)} size="sm"> + {p.newProfile} + + } + description={p.createDesc} + icon="organization" + title={p.noProfiles} + /> ) : ( - - - setCreateOpen(true)} /> - {profiles.map(profile => ( - setSelectedName(profile.name)} - profile={profile} - /> - ))} - {profiles.length === 0 && ( -

{p.noProfiles}

- )} -
+ <> + + + + {visibleProfiles.map(profile => ( + setPendingRename(profile) }, + { + icon: 'trash', + label: t.common.delete, + onSelect: () => setPendingDelete(profile), + tone: 'danger' + } + ] + } + /> + } + onSelect={() => setSelectedName(profile.name)} + profile={profile} + /> + ))} + setCreateOpen(true)} /> + - {selected ? ( - setPendingDelete(selected)} - onRename={newName => handleRename(selected.name, newName)} - profile={selected} - /> + ) : ( -
-
- -

{p.selectPrompt}

-
-
+ )} -
-
+ + )} + setPendingRename(null)} + onRename={async newName => { + if (pendingRename) { + await handleRename(pendingRename.name, newName) + setPendingRename(null) + } + }} + open={pendingRename !== null} + /> + setCreateOpen(false)} onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)} @@ -213,150 +275,106 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { -
+ ) } -function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) { - const { t } = useI18n() - const p = t.profiles - - return ( - - ) -} - -function ProfileDetail({ - onDelete, - onRename, +function ProfileRow({ + active, + menu, + onSelect, profile }: { - onDelete: () => void - onRename: (newName: string) => Promise + active: boolean + menu?: React.ReactNode + onSelect: () => void profile: ProfileInfo }) { - const { t } = useI18n() - const p = t.profiles - const [renameOpen, setRenameOpen] = useState(false) - const [copying, setCopying] = useState(false) + const colors = useStore($profileColors) - const handleCopySetup = useCallback(async () => { - setCopying(true) + return ( + + } + menu={menu} + onSelect={onSelect} + rowKey={profile.name} + title={profile.name} + /> + ) +} - try { - const { command } = await getProfileSetupCommand(profile.name) - await navigator.clipboard.writeText(command) - notify({ kind: 'success', title: p.setupCopied, message: command }) - } catch (err) { - notifyError(err, p.failedCopy) - } finally { - setCopying(false) - } - }, [p, profile.name]) +// Leading glyph for a profile row, mirroring the sidebar rail: the default +// profile gets the `home` icon; named profiles get a soft color-tinted square +// with their initial in the profile's color. +function ProfileGlyph({ color, isDefault, name }: { color: null | string; isDefault: boolean; name: string }) { + if (isDefault) { + return + } - return ( -
-
-
-
-
-
-
-

{profile.name}

- {profile.is_default && ( - - {p.defaultBadge} - - )} - {profile.has_env && ( - - .env - - )} -
-

- {profile.path} -

-
-
- {!profile.is_default && ( - - )} - - {!profile.is_default && ( - - )} -
-
+ const hue = color ?? 'var(--ui-text-quaternary)' -
- - {profile.model ? ( - <> - {profile.model} - {profile.provider && · {profile.provider}} - - ) : ( - {p.notSet} - )} - - {profile.skill_count} -
-
- - -
-
+ const initial = + name + .replace(/[^a-z0-9]/gi, '') + .charAt(0) + .toUpperCase() || '?' - setRenameOpen(false)} - onRename={async newName => { - await onRename(newName) - setRenameOpen(false) - }} - open={renameOpen} - /> -
+ return ( + ) } -function DetailRow({ children, label }: { children: React.ReactNode; label: string }) { +function ProfileDetail({ profile }: { profile: ProfileInfo }) { + const { t } = useI18n() + const p = t.profiles + return ( -
-
{label}
-
{children}
-
+ +
+
+
+

{profile.name}

+ {profile.is_default && {p.defaultBadge}} + {profile.has_env && .env} +
+

+ {profile.path} +

+
+ + + {profile.model} + {profile.provider ? · {profile.provider} : null} + + ) : ( + {p.notSet} + ) + }, + { label: p.skillsLabel, value: profile.skill_count } + ]} + /> +
+ + +
) } @@ -419,7 +437,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
-

SOUL.md

+ SOUL.md

{p.soulDesc}

{dirty && {p.unsavedChanges}} @@ -429,7 +447,7 @@ function SoulEditor({ profileName }: { profileName: string }) { ) : (