mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
commit
2ecb4e62bb
239 changed files with 18356 additions and 2494 deletions
BIN
.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg
vendored
Normal file
BIN
.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
|
|
@ -679,15 +679,28 @@ def recover_with_credential_pool(
|
|||
# long-running TUI sessions stuck on stale tokens until the user
|
||||
# exited and reopened.
|
||||
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
|
||||
_auth_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and "oauth authentication is currently not allowed for this organization" in _auth_haystack
|
||||
):
|
||||
is_entitlement = True
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and (agent.provider or "") == "anthropic"
|
||||
and getattr(agent, "api_mode", "") == "anthropic_messages"
|
||||
):
|
||||
is_entitlement = True
|
||||
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
|
||||
_disambiguator_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
_is_xai_auth_failure = (
|
||||
"[wke=unauthenticated:" in _disambiguator_haystack
|
||||
or "oauth2 access token could not be validated" in _disambiguator_haystack
|
||||
"[wke=unauthenticated:" in _auth_haystack
|
||||
or "oauth2 access token could not be validated" in _auth_haystack
|
||||
)
|
||||
if not _is_xai_auth_failure:
|
||||
is_entitlement = True
|
||||
|
|
|
|||
|
|
@ -1571,6 +1571,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
|
|||
|
||||
if ptype == "input_text":
|
||||
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
|
||||
elif ptype == "text":
|
||||
# A stored Anthropic text block. Rebuild from whitelisted fields only —
|
||||
# SDK response text blocks carry output-only siblings (parsed_output,
|
||||
# citations=None) that the Messages INPUT schema rejects with HTTP 400
|
||||
# "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
|
||||
block = {"type": "text", "text": part.get("text", "")}
|
||||
cits = part.get("citations")
|
||||
if isinstance(cits, list) and cits:
|
||||
block["citations"] = cits
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_value = part.get("image_url", {})
|
||||
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
|
||||
|
|
@ -1685,6 +1694,58 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
|||
return out
|
||||
|
||||
|
||||
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Strip output-only fields from a stored Anthropic content block so it is
|
||||
valid as REQUEST input on replay.
|
||||
|
||||
The SDK response objects carry output-only attributes that the Messages
|
||||
*input* schema forbids ("Extra inputs are not permitted"): text blocks get
|
||||
``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
|
||||
etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
|
||||
so these leak back as input on the next turn → HTTP 400.
|
||||
|
||||
Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
|
||||
reintroduce the bug. Returns a clean block, or None to drop it.
|
||||
"""
|
||||
if not isinstance(b, dict):
|
||||
return None
|
||||
btype = b.get("type")
|
||||
if btype == "text":
|
||||
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
|
||||
# citations is input-valid ONLY when it's a non-empty list; the SDK
|
||||
# emits citations=None on responses, which the input schema rejects.
|
||||
cits = b.get("citations")
|
||||
if isinstance(cits, list) and cits:
|
||||
out["citations"] = cits
|
||||
if isinstance(b.get("cache_control"), dict):
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
if btype == "thinking":
|
||||
out = {"type": "thinking", "thinking": b.get("thinking", "")}
|
||||
if b.get("signature"):
|
||||
out["signature"] = b["signature"]
|
||||
return out
|
||||
if btype == "redacted_thinking":
|
||||
# Only valid with its data payload; drop if missing.
|
||||
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
|
||||
if btype == "tool_use":
|
||||
out = {
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(b.get("id", "")),
|
||||
"name": b.get("name", ""),
|
||||
"input": b.get("input", {}),
|
||||
}
|
||||
if isinstance(b.get("cache_control"), dict):
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
if btype == "image":
|
||||
src = b.get("source")
|
||||
return {"type": "image", "source": src} if isinstance(src, dict) else None
|
||||
# Unknown/unsupported block type on the input path — drop rather than risk
|
||||
# another "Extra inputs are not permitted".
|
||||
return None
|
||||
|
||||
|
||||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert an assistant message to Anthropic content blocks.
|
||||
|
||||
|
|
@ -1692,6 +1753,55 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
|||
reasoning_content injection for Kimi/DeepSeek endpoints.
|
||||
"""
|
||||
content = m.get("content", "")
|
||||
# Anthropic interleaved-thinking fast path: when this turn carries a
|
||||
# verbatim, order-preserving block list (set by normalize_response only
|
||||
# for turns that interleave SIGNED thinking with tool_use), replay it.
|
||||
# Each block is run through _sanitize_replay_block to strip output-only
|
||||
# SDK fields (parsed_output, caller, citations=None, …) that the Messages
|
||||
# INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra
|
||||
# inputs are not permitted" (text.parsed_output). Block ORDER is preserved
|
||||
# (the reason this channel exists); only forbidden sibling fields are
|
||||
# dropped, leaving thinking signatures and tool_use id/name/input intact.
|
||||
ordered_blocks = m.get("anthropic_content_blocks")
|
||||
if isinstance(ordered_blocks, list) and ordered_blocks:
|
||||
# Re-source each tool_use input from the stored tool_calls map rather
|
||||
# than the captured block. The ordered-blocks list captures tool_use
|
||||
# input from the RAW API response (normalize_response), which is NOT
|
||||
# credential-redacted; tool_calls[].function.arguments IS redacted at
|
||||
# storage time (build_assistant_message, #19798). Replaying the raw
|
||||
# block input would resurrect a secret the model inlined into a tool
|
||||
# call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'")
|
||||
# onto the wire, even though the same value is redacted everywhere else
|
||||
# in history. Keying by sanitized tool id preserves interleave order
|
||||
# (the reason this channel exists) while swapping in the redacted
|
||||
# input. Adapted from #36071 (replay-time tool-input re-sourcing).
|
||||
redacted_input_by_id: Dict[str, Any] = {}
|
||||
for tc in m.get("tool_calls", []) or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {}) or {}
|
||||
raw_args = fn.get("arguments", "{}")
|
||||
try:
|
||||
parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed_args = {}
|
||||
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
for b in ordered_blocks:
|
||||
clean = _sanitize_replay_block(b)
|
||||
if clean is None:
|
||||
continue
|
||||
if clean.get("type") == "tool_use":
|
||||
# Override raw (un-redacted) input with the redacted copy when
|
||||
# we have one for this id; fall back to the sanitized block
|
||||
# input only if the tool_call is missing (shape mismatch).
|
||||
redacted = redacted_input_by_id.get(clean.get("id", ""))
|
||||
if redacted is not None:
|
||||
clean["input"] = redacted
|
||||
replayed.append(clean)
|
||||
if replayed:
|
||||
return {"role": "assistant", "content": replayed}
|
||||
|
||||
blocks = _extract_preserved_thinking_blocks(m)
|
||||
if content:
|
||||
if isinstance(content, list):
|
||||
|
|
|
|||
|
|
@ -208,6 +208,41 @@ def is_stale_connection_error(exc: BaseException) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def is_streaming_access_denied_error(exc: BaseException) -> bool:
|
||||
"""Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action.
|
||||
|
||||
IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege
|
||||
setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose
|
||||
message names the streaming action, e.g.::
|
||||
|
||||
User: arn:aws:iam::123456789012:user/x is not authorized to perform:
|
||||
bedrock:InvokeModelWithResponseStream on resource: ...
|
||||
|
||||
This is permanent for the session — retrying the stream can never succeed —
|
||||
so callers should flip to the non-streaming ``converse()`` path (which maps
|
||||
to ``bedrock:InvokeModel``) instead of burning retries.
|
||||
|
||||
Detection is deliberately message-based: boto3 surfaces this as a
|
||||
``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the
|
||||
AnthropicBedrock SDK wraps the same AWS response in its own exception
|
||||
types, but both preserve the action name in the message.
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
if "invokemodelwithresponsestream" not in msg:
|
||||
return False
|
||||
# ClientError with an explicit access-denied code is the canonical form.
|
||||
try:
|
||||
from botocore.exceptions import ClientError
|
||||
except ImportError: # pragma: no cover — botocore always present with boto3
|
||||
ClientError = None # type: ignore[assignment]
|
||||
if ClientError is not None and isinstance(exc, ClientError):
|
||||
code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "")
|
||||
return code in ("AccessDeniedException", "UnauthorizedException")
|
||||
# Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match
|
||||
# on the authorization-failure phrasing AWS uses.
|
||||
return "not authorized" in msg or "accessdenied" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWS credential detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1003,6 +1038,16 @@ def call_converse_stream(
|
|||
try:
|
||||
response = client.converse_stream(**kwargs)
|
||||
except Exception as exc:
|
||||
if is_streaming_access_denied_error(exc):
|
||||
# IAM allows bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream — permanent for this session.
|
||||
# Fall back to the non-streaming converse() path.
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
|
||||
"falling back to non-streaming converse().",
|
||||
region, model,
|
||||
)
|
||||
return normalize_converse_response(client.converse(**kwargs))
|
||||
if is_stale_connection_error(exc):
|
||||
logger.warning(
|
||||
"bedrock: stale-connection error on converse_stream(region=%s, "
|
||||
|
|
|
|||
|
|
@ -952,6 +952,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
|||
if preserved:
|
||||
msg["reasoning_details"] = preserved
|
||||
|
||||
# Anthropic interleaved-thinking replay: when a turn interleaves signed
|
||||
# thinking blocks with tool_use, the parallel reasoning_details +
|
||||
# tool_calls fields lose the cross-type ordering, and reconstruction
|
||||
# front-loads thinking — reordering signed blocks and triggering HTTP 400
|
||||
# ("thinking ... blocks in the latest assistant message cannot be
|
||||
# modified"). Carry the verbatim ordered block list so the adapter can
|
||||
# replay the latest assistant message unchanged. See
|
||||
# agent/transports/anthropic.py and agent/anthropic_adapter.py.
|
||||
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
|
||||
if ordered_blocks:
|
||||
msg["anthropic_content_blocks"] = ordered_blocks
|
||||
|
||||
# Codex Responses API: preserve encrypted reasoning items for
|
||||
# multi-turn continuity. These get replayed as input on the next turn.
|
||||
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
|
||||
|
|
@ -1603,6 +1615,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
is_streaming_access_denied_error,
|
||||
normalize_converse_response,
|
||||
stream_converse_with_callbacks,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
|
|
@ -1611,6 +1625,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
try:
|
||||
raw_response = client.converse_stream(**api_kwargs)
|
||||
except Exception as _bedrock_exc:
|
||||
# IAM policies scoped to bedrock:InvokeModel only (no
|
||||
# InvokeModelWithResponseStream) reject converse_stream()
|
||||
# with AccessDeniedException. That denial is permanent for
|
||||
# the session — fall back to the non-streaming converse()
|
||||
# inline (it maps to bedrock:InvokeModel) and disable
|
||||
# streaming for subsequent calls so we don't re-fail every
|
||||
# turn.
|
||||
if is_streaming_access_denied_error(_bedrock_exc):
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
|
||||
"falling back to non-streaming InvokeModel.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
)
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM (%s) — "
|
||||
"using non-streaming converse() for this session.",
|
||||
type(_bedrock_exc).__name__,
|
||||
)
|
||||
result["response"] = normalize_converse_response(
|
||||
client.converse(**api_kwargs)
|
||||
)
|
||||
return
|
||||
# Evict the cached client on stale-connection failures
|
||||
# so the outer retry loop builds a fresh client/pool.
|
||||
if is_stale_connection_error(_bedrock_exc):
|
||||
|
|
@ -1698,6 +1735,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
# poll loop uses this to detect stale connections that keep receiving
|
||||
# SSE keep-alive pings but no actual data.
|
||||
last_chunk_time = {"t": time.time()}
|
||||
# Stale-stream patience, shared between the httpx socket read timeout
|
||||
# (built in ``_call_chat_completions`` below) and the stale-stream detector
|
||||
# (computed further down, before the worker thread starts). Initialized
|
||||
# here so the read-timeout builder can floor itself at the stale value and
|
||||
# never fire before the detector. ``None`` until the detector value is
|
||||
# resolved, so the builder degrades to its plain default if it ever runs
|
||||
# first.
|
||||
_stream_stale_timeout = None
|
||||
|
||||
def _fire_first_delta():
|
||||
if not first_delta_fired["done"] and on_first_delta:
|
||||
|
|
@ -1734,6 +1779,26 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
"Local provider detected (%s) — stream read timeout raised to %.0fs",
|
||||
agent.base_url, _stream_read_timeout,
|
||||
)
|
||||
elif (
|
||||
_stream_read_timeout == 120.0
|
||||
and _stream_stale_timeout is not None
|
||||
and _stream_stale_timeout != float("inf")
|
||||
and _stream_stale_timeout > _stream_read_timeout
|
||||
):
|
||||
# Cloud reasoning models (e.g. Opus) routinely pause mid-stream
|
||||
# for minutes during extended thinking. The stale-stream
|
||||
# detector is deliberately scaled up to tolerate this (180–300s,
|
||||
# see the stale-timeout block below), but the raw httpx socket
|
||||
# read timeout defaulted to a flat 120s and fired *first* —
|
||||
# tearing down a healthy reasoning stream before the stale
|
||||
# detector (which owns retry + diagnostics) could act. Keep the
|
||||
# socket read timeout in step with the detector so it no longer
|
||||
# preempts it.
|
||||
_stream_read_timeout = _stream_stale_timeout
|
||||
logger.debug(
|
||||
"Cloud reasoning stream — read timeout raised to %.0fs to "
|
||||
"match stale-stream detector", _stream_read_timeout,
|
||||
)
|
||||
# Cap connect/pool at 60s even when provider timeout is higher.
|
||||
# connect/pool cover TCP handshake, not model inference.
|
||||
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
|
||||
|
|
@ -2384,9 +2449,34 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
"stream" in _err_lower
|
||||
and "not supported" in _err_lower
|
||||
)
|
||||
if _is_stream_unsupported:
|
||||
# AWS Bedrock (AnthropicBedrock SDK path): IAM policies
|
||||
# with bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream reject messages.stream()
|
||||
# with a permission error naming the streaming action.
|
||||
# Permanent for the session — flip to non-streaming
|
||||
# (messages.create() maps to bedrock:InvokeModel).
|
||||
_is_bedrock_stream_denied = False
|
||||
if (
|
||||
not _is_stream_unsupported
|
||||
and "invokemodelwithresponsestream" in _err_lower
|
||||
):
|
||||
# Cheap message pre-check before importing the
|
||||
# adapter — bedrock_adapter triggers a lazy boto3
|
||||
# install at import time, which must not run for
|
||||
# unrelated providers' stream errors.
|
||||
from agent.bedrock_adapter import (
|
||||
is_streaming_access_denied_error,
|
||||
)
|
||||
_is_bedrock_stream_denied = (
|
||||
is_streaming_access_denied_error(e)
|
||||
)
|
||||
if _is_stream_unsupported or _is_bedrock_stream_denied:
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
|
||||
"Switching to non-streaming.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
if _is_bedrock_stream_denied else
|
||||
"\n⚠ Streaming is not supported for this "
|
||||
"model/provider. Switching to non-streaming.\n"
|
||||
" To avoid this delay, set display.streaming: false "
|
||||
|
|
|
|||
700
agent/coding_context.py
Normal file
700
agent/coding_context.py
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
"""Coding-context awareness — base Hermes, every interactive surface.
|
||||
|
||||
When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
|
||||
editor over ACP), Hermes shifts into a **coding posture**. This module is the
|
||||
single place that decides whether we're in that posture and what it implies,
|
||||
so the rest of the codebase never re-derives "are we coding?" on its own.
|
||||
|
||||
Architecture — one seam, many consumers
|
||||
----------------------------------------
|
||||
The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
|
||||
:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
|
||||
is *data* — it declares the toolset to collapse to, the operating brief to
|
||||
inject, and hints for other domains (model routing, memory, subagents). Every
|
||||
domain reads the same resolved object instead of probing git/config itself:
|
||||
|
||||
* **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief +
|
||||
a live git/workspace snapshot (``agent/system_prompt.py``).
|
||||
* **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset
|
||||
plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
|
||||
under the opt-in ``focus`` mode: the default posture is prompt-only and
|
||||
never touches the user's configured toolsets (toolsets like messaging /
|
||||
smart-home / music are off-by-default anyway, and someone who explicitly
|
||||
enabled image-gen or Spotify shouldn't lose it for being in a git repo).
|
||||
* **Delegation** — subagents inherit the parent's toolset and run through the
|
||||
same prompt builder, so the coding posture propagates to children for free.
|
||||
* **Model / memory / compression** — declared on the profile
|
||||
(``model_hint``, ``memory_policy``) as the extension seam; consumers read
|
||||
``mode.profile`` rather than re-deciding.
|
||||
|
||||
Cache safety
|
||||
------------
|
||||
The mode is resolved **once** and is immutable. The workspace snapshot is built
|
||||
once at prompt-build time and baked into the *stable* system-prompt tier — never
|
||||
re-probed per turn (that would shatter the prompt cache). Branch and dirty state
|
||||
drift mid-session, so the brief tells the model to re-check with ``git`` before
|
||||
acting on the snapshot. A ``/coding`` flip therefore only takes effect next
|
||||
session (deferred), the same contract as ``/skills install`` vs ``--now``.
|
||||
|
||||
Activation (config ``agent.coding_context``):
|
||||
|
||||
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
|
||||
surface sitting in a code workspace (git repo or recognised project root).
|
||||
Prompt-only; toolsets untouched.
|
||||
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
|
||||
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
|
||||
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
|
||||
* ``off`` — disable entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("hermes.coding_context")
|
||||
|
||||
CODING_TOOLSET = "coding"
|
||||
|
||||
# Surfaces where a coding posture makes sense under ``auto``. Messaging
|
||||
# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot
|
||||
# in a group is not pair-programming.
|
||||
INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
|
||||
|
||||
# Project-root signals that mark a directory as a code workspace even when it
|
||||
# isn't (yet) a git repo. Cheap filename checks — no parsing.
|
||||
_PROJECT_MARKERS = (
|
||||
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
|
||||
"package.json", "tsconfig.json", "deno.json",
|
||||
"Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
|
||||
"Gemfile", "composer.json", "mix.exs", "pubspec.yaml",
|
||||
"CMakeLists.txt", "Makefile", "Dockerfile",
|
||||
"AGENTS.md", "CLAUDE.md", ".cursorrules",
|
||||
)
|
||||
|
||||
# Agent-instruction files surfaced separately from manifests in the snapshot.
|
||||
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
|
||||
|
||||
# Lockfile → package manager, checked in priority order.
|
||||
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
|
||||
_JS_LOCKFILES = (
|
||||
("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
|
||||
("yarn.lock", "yarn"), ("package-lock.json", "npm"),
|
||||
)
|
||||
|
||||
# package.json scripts / Makefile targets worth surfacing as verify commands.
|
||||
_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
|
||||
_MAX_VERIFY_COMMANDS = 8
|
||||
_MAX_FACT_FILE_BYTES = 256 * 1024
|
||||
|
||||
_GIT_TIMEOUT = 2.5
|
||||
|
||||
|
||||
# Per-model edit-format steering. Matching the edit tool format to how a model
|
||||
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
|
||||
# patch-style diffs best; Anthropic models — and most open-weight coding
|
||||
# models, whose RL scaffolds use str_replace-style editors — do best with
|
||||
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
|
||||
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
|
||||
# its native format. Unknown families get nothing (the brief's neutral wording
|
||||
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
|
||||
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
|
||||
"patch": (
|
||||
("gpt", "codex"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code prefer `patch` with `mode='patch'` (V4A multi-file diff) "
|
||||
"for structured or multi-file changes — it's the diff format you handle "
|
||||
"most reliably. Use `mode='replace'` for a single small swap.",
|
||||
),
|
||||
"replace": (
|
||||
("claude", "sonnet", "opus", "haiku",
|
||||
"gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
|
||||
"hermes", "llama", "mistral", "devstral", "minimax"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code prefer `patch` in `mode='replace'` — match a unique "
|
||||
"snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
|
||||
"genuinely spans several files at once.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _model_family(model: Optional[str]) -> Optional[str]:
|
||||
"""Classify a model id into an edit-format family key, or ``None``.
|
||||
|
||||
Used to steer the coding posture toward the edit tool format a model was
|
||||
trained on. Family-agnostic by design: an unrecognised model gets ``None``
|
||||
and the operating brief's neutral edit wording applies.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
lowered = model.lower()
|
||||
for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
|
||||
if any(n in lowered for n in needles):
|
||||
return family
|
||||
return None
|
||||
|
||||
|
||||
def _edit_format_line(model: Optional[str]) -> str:
|
||||
"""The edit-format guidance line for this model's family (``""`` if none)."""
|
||||
family = _model_family(model)
|
||||
if family is None:
|
||||
return ""
|
||||
return _EDIT_FORMAT_GUIDANCE[family][1]
|
||||
|
||||
|
||||
# Operating brief for the coding posture. Tool names referenced here (read_file,
|
||||
# search_files, patch, write_file, terminal, todo) are in the coding toolset and
|
||||
# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on.
|
||||
CODING_AGENT_GUIDANCE = (
|
||||
"You are a coding agent pairing with the user inside their codebase. "
|
||||
"Operate like a careful senior engineer.\n"
|
||||
"\n"
|
||||
"Gather context first:\n"
|
||||
"- Read the relevant files with `read_file` and locate code with "
|
||||
"`search_files` before changing anything. Trace a symbol to its definition "
|
||||
"and usages rather than guessing its shape.\n"
|
||||
"- Batch independent lookups: when several reads/searches don't depend on "
|
||||
"each other, issue them together in one turn instead of one at a time.\n"
|
||||
"- Never invent files, symbols, APIs, or imports. If you haven't seen it in "
|
||||
"the repo, go look. Don't assume a library is available — check the project "
|
||||
"manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how "
|
||||
"neighbouring files import it.\n"
|
||||
"\n"
|
||||
"Make changes through the tools, not the chat:\n"
|
||||
"- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as "
|
||||
"a substitute for editing — apply the change, then summarise it. Only show "
|
||||
"code when the user explicitly asks to see it.\n"
|
||||
"- Match the project's existing style and conventions; AGENTS.md / "
|
||||
"CLAUDE.md / .cursorrules already in context win over your defaults. Touch "
|
||||
"only what the task needs — no drive-by refactors, renames, or reformatting "
|
||||
"— and add any imports/dependencies your code requires.\n"
|
||||
"- If an edit fails to apply, re-read the file to get the current exact "
|
||||
"contents before retrying — don't repeat a stale patch. If the same region "
|
||||
"fails twice, rewrite the enclosing function or file with `write_file` "
|
||||
"instead of attempting a third patch.\n"
|
||||
"\n"
|
||||
"Verify, and know when to stop:\n"
|
||||
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
|
||||
"tests/linter/build and confirm they pass before claiming the work is done.\n"
|
||||
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
|
||||
"paths for the same flaw and fix the class, not just the reported site.\n"
|
||||
"- When fixing linter/type errors on a file, stop after about three "
|
||||
"attempts on the same file and ask the user rather than looping.\n"
|
||||
"- Track multi-step work with `todo`. Reference code as `path:line` instead "
|
||||
"of pasting whole files.\n"
|
||||
"\n"
|
||||
"Respect the user's repo: don't commit, push, or rewrite history unless "
|
||||
"asked, and never read, print, or commit secrets — leave `.env` and "
|
||||
"credential files alone unless the user explicitly asks. The Workspace "
|
||||
"block below is a snapshot from session start — re-run `git status`/"
|
||||
"`git branch` before relying on it. Be concise: lead with the change or "
|
||||
"answer, not a preamble."
|
||||
)
|
||||
|
||||
|
||||
# ── Context profiles (declarative posture definitions) ──────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextProfile:
|
||||
"""A named operating posture. Pure data — consumers read these fields.
|
||||
|
||||
``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit
|
||||
selection is pinned; ``None`` keeps the platform default.
|
||||
``guidance`` — operating brief injected into the stable system prompt;
|
||||
``""`` injects nothing.
|
||||
``model_hint`` — routing preference key for smart model routing
|
||||
(extension seam; not yet consumed by the router).
|
||||
``memory_policy``— memory namespace/weighting hint (extension seam).
|
||||
``hidden_skill_categories`` — skill categories pruned from the system-prompt
|
||||
skill index while this posture is active. Discovery-only:
|
||||
nothing is disabled — ``skills_list`` still returns the
|
||||
full catalog and ``skill_view`` loads anything. Deny-list
|
||||
semantics so unknown/custom categories stay visible.
|
||||
"""
|
||||
|
||||
name: str
|
||||
toolset: Optional[str] = None
|
||||
guidance: str = ""
|
||||
model_hint: Optional[str] = None
|
||||
memory_policy: str = "default"
|
||||
hidden_skill_categories: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# Skill categories that are clearly not part of a coding workflow. Hidden from
|
||||
# the prompt's skill index in the coding posture (deny-list — anything not
|
||||
# listed here, incl. custom user categories, stays visible). Coding-adjacent
|
||||
# categories (devops, github, mcp, data-science, diagramming, research,
|
||||
# security, …) are intentionally absent.
|
||||
_NON_CODING_SKILL_CATEGORIES = (
|
||||
"apple", "communication", "cooking", "creative", "email", "finance",
|
||||
"gaming", "gifs", "health", "media", "music", "note-taking",
|
||||
"productivity", "shopping", "smart-home", "social-media", "travel",
|
||||
"yuanbao",
|
||||
)
|
||||
|
||||
|
||||
GENERAL_PROFILE = ContextProfile(name="general")
|
||||
CODING_PROFILE = ContextProfile(
|
||||
name="coding",
|
||||
toolset=CODING_TOOLSET,
|
||||
guidance=CODING_AGENT_GUIDANCE,
|
||||
model_hint="coding",
|
||||
memory_policy="project",
|
||||
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||
)
|
||||
|
||||
_PROFILES: dict[str, ContextProfile] = {
|
||||
GENERAL_PROFILE.name: GENERAL_PROFILE,
|
||||
CODING_PROFILE.name: CODING_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
def get_profile(name: str) -> ContextProfile:
|
||||
"""Return a registered profile, falling back to ``general``."""
|
||||
return _PROFILES.get(name, GENERAL_PROFILE)
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _coding_mode(config: Optional[dict[str, Any]]) -> str:
|
||||
"""Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
|
||||
mode = str(raw).strip().lower()
|
||||
if mode in {"focus", "strict", "lean"}:
|
||||
return "focus"
|
||||
if mode in {"on", "true", "yes", "1", "always"}:
|
||||
return "on"
|
||||
if mode in {"off", "false", "no", "0", "never"}:
|
||||
return "off"
|
||||
return "auto"
|
||||
|
||||
|
||||
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
|
||||
if cwd:
|
||||
return Path(cwd).expanduser()
|
||||
try:
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
return resolve_agent_cwd()
|
||||
except Exception:
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def _git_root(cwd: Path) -> Optional[Path]:
|
||||
current = cwd.resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _home() -> Optional[Path]:
|
||||
try:
|
||||
return Path.home().resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _marker_root(cwd: Path) -> Optional[Path]:
|
||||
"""Nearest ancestor that looks like a project root, or ``None``.
|
||||
|
||||
Walks up at most a few levels so a manifest in the workspace root counts
|
||||
even when the user is in a subdirectory. ``$HOME`` itself is skipped — a
|
||||
Makefile or AGENTS.md sitting in the home directory is global user config,
|
||||
not a project-root signal.
|
||||
"""
|
||||
current = cwd.resolve()
|
||||
home = _home()
|
||||
for depth, parent in enumerate([current, *current.parents]):
|
||||
if depth > 6:
|
||||
break
|
||||
if parent == home:
|
||||
continue
|
||||
for marker in _PROJECT_MARKERS:
|
||||
if (parent / marker).exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
|
||||
"""Resolve which profile applies.
|
||||
|
||||
``auto``/``focus``: coding when the surface is interactive AND the cwd is a
|
||||
code workspace (a git repo or a recognised project root). ``on``: always
|
||||
coding. ``off``: always general.
|
||||
|
||||
A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace
|
||||
signal — without the guard, every session anywhere under a dotfiles-managed
|
||||
home directory would silently flip to the coding posture.
|
||||
|
||||
Detection is intentionally not memoized: it's a handful of ``stat`` calls,
|
||||
and callers resolve the mode once per session anyway. Caching here would
|
||||
risk a stale posture if a long-lived process (gateway/TUI) serves sessions
|
||||
from different working directories.
|
||||
"""
|
||||
if mode == "off":
|
||||
return GENERAL_PROFILE.name
|
||||
if mode == "on":
|
||||
return CODING_PROFILE.name
|
||||
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
|
||||
return GENERAL_PROFILE.name
|
||||
cwd = Path(cwd_str)
|
||||
git_root = _git_root(cwd)
|
||||
if git_root is not None and git_root == _home():
|
||||
git_root = None # dotfiles repo at $HOME — not a code workspace
|
||||
if git_root is not None or _marker_root(cwd) is not None:
|
||||
return CODING_PROFILE.name
|
||||
return GENERAL_PROFILE.name
|
||||
|
||||
|
||||
# ── RuntimeMode (the seam) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeMode:
|
||||
"""The resolved operating posture for a session. Immutable by construction.
|
||||
|
||||
Built once via :func:`resolve_runtime_mode` and consumed by every domain
|
||||
that cares about the coding/general distinction. Never mutate or re-resolve
|
||||
mid-session — that would break the prompt cache.
|
||||
"""
|
||||
|
||||
profile: ContextProfile
|
||||
surface: str
|
||||
cwd: Path
|
||||
# The normalized ``agent.coding_context`` mode this posture was resolved
|
||||
# under (auto/focus/on/off). Toolset collapse is gated on ``focus``.
|
||||
config_mode: str = "auto"
|
||||
# The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
|
||||
# only to steer edit-format guidance toward the model's family — see
|
||||
# ``_edit_format_line``. Fixed for the session, so cache-safe.
|
||||
model: Optional[str] = None
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return self.profile.name
|
||||
|
||||
@property
|
||||
def is_coding(self) -> bool:
|
||||
return self.profile.name == CODING_PROFILE.name
|
||||
|
||||
def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]:
|
||||
"""Toolset list for this posture, or ``None`` to keep the platform default.
|
||||
|
||||
Non-``None`` only under the opt-in ``focus`` mode. The default posture
|
||||
is prompt-only: most strippable toolsets are off-by-default anyway, and
|
||||
a user who explicitly enabled one (image-gen for frontend/game assets,
|
||||
messaging for build notifications, …) keeps it while coding.
|
||||
|
||||
Callers apply this only when the user hasn't pinned an explicit
|
||||
selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never
|
||||
override a pin. Returns the profile's toolset plus enabled MCP servers.
|
||||
"""
|
||||
if self.config_mode != "focus":
|
||||
return None
|
||||
if self.profile.toolset is None:
|
||||
return None
|
||||
return [self.profile.toolset, *_enabled_mcp_servers(config)]
|
||||
|
||||
def system_blocks(self) -> list[str]:
|
||||
"""Stable system-prompt blocks for this posture (brief + workspace).
|
||||
|
||||
The operating brief carries a model-family edit-format nudge appended
|
||||
to it (one cached string, not a separate block) so the model is steered
|
||||
toward the `patch` mode it handles best — see ``_edit_format_line``.
|
||||
"""
|
||||
if not self.is_coding:
|
||||
return []
|
||||
blocks: list[str] = []
|
||||
if self.profile.guidance:
|
||||
brief = self.profile.guidance
|
||||
edit_line = _edit_format_line(self.model)
|
||||
if edit_line:
|
||||
brief = f"{brief}\n{edit_line}"
|
||||
blocks.append(brief)
|
||||
workspace = build_coding_workspace_block(self.cwd)
|
||||
if workspace:
|
||||
blocks.append(workspace)
|
||||
return blocks
|
||||
|
||||
def hidden_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to prune from the prompt's skill index (may be empty)."""
|
||||
return frozenset(self.profile.hidden_skill_categories)
|
||||
|
||||
|
||||
def resolve_runtime_mode(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> RuntimeMode:
|
||||
"""Resolve the operating posture once. Cheap — a handful of ``stat`` calls.
|
||||
|
||||
This is the single entry point every domain should call. The returned
|
||||
object is immutable and safe to cache for the session. Detection itself is
|
||||
intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived
|
||||
process can't pin a stale posture; callers resolve once per session and
|
||||
hold the result. ``model`` is recorded only to steer edit-format guidance;
|
||||
it never affects detection.
|
||||
"""
|
||||
resolved_cwd = _resolve_cwd(cwd)
|
||||
mode = _coding_mode(config)
|
||||
name = _detect_profile_name(
|
||||
mode, (platform or "").strip().lower(), str(resolved_cwd)
|
||||
)
|
||||
return RuntimeMode(
|
||||
profile=get_profile(name),
|
||||
surface=platform or "",
|
||||
cwd=resolved_cwd,
|
||||
config_mode=mode,
|
||||
model=model,
|
||||
)
|
||||
|
||||
|
||||
# ── Back-compat surface (thin wrappers over RuntimeMode) ────────────────────
|
||||
|
||||
|
||||
def is_coding_context(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Whether Hermes should operate in its coding posture right now."""
|
||||
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding
|
||||
|
||||
|
||||
def coding_selection(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[list[str]]:
|
||||
"""Toolset selection for the coding posture.
|
||||
|
||||
``None`` unless the user opted into ``focus`` mode AND the posture is
|
||||
active — the default coding posture never overrides configured toolsets.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).toolset_selection(config)
|
||||
|
||||
|
||||
def coding_system_blocks(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
"""Stable system-prompt blocks for the current posture (empty when general).
|
||||
|
||||
``model`` steers the brief's edit-format nudge toward the model's family.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config, model=model
|
||||
).system_blocks()
|
||||
|
||||
|
||||
def coding_hidden_skill_categories(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> frozenset[str]:
|
||||
"""Skill categories the active posture prunes from the prompt's skill index.
|
||||
|
||||
Empty outside the coding posture. Discovery-only: hidden skills remain
|
||||
loadable via ``skills_list`` / ``skill_view``.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).hidden_skill_categories()
|
||||
|
||||
|
||||
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
"""Names of MCP servers the user has enabled — kept in the coding posture.
|
||||
|
||||
MCP servers (figma, browser, tophat, …) are explicitly configured and part
|
||||
of the coding workflow, not noise to strip.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
servers = read_raw_config().get("mcp_servers") or {}
|
||||
return [
|
||||
str(name)
|
||||
for name, cfg in servers.items()
|
||||
if isinstance(cfg, dict)
|
||||
and _parse_enabled_flag(cfg.get("enabled", True), default=True)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── git/workspace probe ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return out.stdout.strip() if out.returncode == 0 else ""
|
||||
|
||||
|
||||
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
|
||||
"""Parse ``git status --porcelain=2 --branch`` into branch + counts."""
|
||||
branch: dict[str, str] = {}
|
||||
counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
|
||||
for line in porcelain.splitlines():
|
||||
if line.startswith("# branch.head"):
|
||||
branch["head"] = line.split(maxsplit=2)[-1]
|
||||
elif line.startswith("# branch.upstream"):
|
||||
branch["upstream"] = line.split(maxsplit=2)[-1]
|
||||
elif line.startswith("# branch.ab"):
|
||||
parts = line.split()
|
||||
branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
|
||||
elif line.startswith(("1 ", "2 ")):
|
||||
xy = line.split(maxsplit=2)[1]
|
||||
if xy[0] != ".":
|
||||
counts["staged"] += 1
|
||||
if xy[1] != ".":
|
||||
counts["modified"] += 1
|
||||
elif line.startswith("u "):
|
||||
counts["conflicts"] += 1
|
||||
elif line.startswith("? "):
|
||||
counts["untracked"] += 1
|
||||
return branch, counts
|
||||
|
||||
|
||||
def _read_small(path: Path) -> str:
|
||||
"""Read a small text file, or ``""`` — never raises, never reads huge files."""
|
||||
try:
|
||||
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
|
||||
return ""
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _project_facts(root: Path) -> list[str]:
|
||||
"""Detected project facts for the workspace snapshot.
|
||||
|
||||
The point is to hand the model its *verify loop* up front — which manifest,
|
||||
which package manager, and the exact test/lint/build commands — instead of
|
||||
making it rediscover them every session. Cheap: stat calls plus reads of a
|
||||
couple of small files; built once at prompt-build time (cache-safe).
|
||||
"""
|
||||
facts: list[str] = []
|
||||
|
||||
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
|
||||
package_managers = [
|
||||
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
|
||||
]
|
||||
if manifests:
|
||||
line = f"- Project: {', '.join(manifests[:6])}"
|
||||
if package_managers:
|
||||
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
|
||||
facts.append(line)
|
||||
|
||||
verify: list[str] = []
|
||||
if (root / "scripts" / "run_tests.sh").is_file():
|
||||
verify.append("scripts/run_tests.sh")
|
||||
if (root / "package.json").is_file():
|
||||
try:
|
||||
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
scripts = {}
|
||||
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
|
||||
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
|
||||
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
|
||||
verify.append("pytest")
|
||||
makefile = _read_small(root / "Makefile")
|
||||
if makefile:
|
||||
verify.extend(
|
||||
f"make {name}" for name in _VERIFY_TARGETS
|
||||
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
|
||||
)
|
||||
if verify:
|
||||
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
|
||||
facts.append(f"- Verify: {'; '.join(deduped)}")
|
||||
|
||||
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
|
||||
if context_files:
|
||||
facts.append(f"- Context files: {', '.join(context_files)}")
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||
"""Workspace snapshot for the system prompt (empty outside a workspace).
|
||||
|
||||
Git state (branch/status/commits) when the cwd is in a repo, plus detected
|
||||
project facts (manifest, package manager, verify commands, context files)
|
||||
— so marker-only (non-git) projects still get a snapshot.
|
||||
"""
|
||||
resolved = _resolve_cwd(cwd)
|
||||
git_root = _git_root(resolved)
|
||||
root = git_root or _marker_root(resolved)
|
||||
if root is None:
|
||||
return ""
|
||||
|
||||
lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
|
||||
lines.append(f"- Root: {root}")
|
||||
|
||||
if git_root is not None:
|
||||
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
|
||||
head = branch.get("head", "")
|
||||
if head and head != "(detached)":
|
||||
line = f"- Branch: {head}"
|
||||
if branch.get("upstream"):
|
||||
line += f" \u2192 {branch['upstream']}"
|
||||
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
|
||||
if ahead != "0" or behind != "0":
|
||||
line += f" (ahead {ahead}, behind {behind})"
|
||||
lines.append(line)
|
||||
elif head == "(detached)":
|
||||
lines.append("- Branch: (detached HEAD)")
|
||||
|
||||
# Linked worktree: the per-worktree git dir differs from the shared common dir.
|
||||
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
|
||||
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
|
||||
main_tree = Path(common_dir).resolve().parent
|
||||
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
|
||||
|
||||
dirty = [f"{n} {label}" for label, n in (
|
||||
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||
("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]),
|
||||
) if n]
|
||||
lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
|
||||
|
||||
recent = _git(root, "log", "-3", "--pretty=%h %s")
|
||||
if recent:
|
||||
lines.append("- Recent commits:")
|
||||
lines.extend(f" {c}" for c in recent.splitlines())
|
||||
|
||||
lines.extend(_project_facts(root))
|
||||
return "\n".join(lines)
|
||||
|
|
@ -2221,30 +2221,54 @@ def run_conversation(
|
|||
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
|
||||
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
|
||||
|
||||
# ── Thinking block signature recovery ─────────────────
|
||||
# Thinking block signature recovery.
|
||||
#
|
||||
# Anthropic signs thinking blocks against the full turn
|
||||
# content. Any upstream mutation (context compression,
|
||||
# content. Any upstream mutation (context compression,
|
||||
# session truncation, message merging) invalidates the
|
||||
# signature → HTTP 400. Recovery: strip reasoning_details
|
||||
# from all messages so the next retry sends no thinking
|
||||
# blocks at all. One-shot — don't retry infinitely.
|
||||
# signature and the API replies HTTP 400 ("invalid
|
||||
# signature" or "cannot be modified"). Recovery strips
|
||||
# ``reasoning_details`` so the retry sends no thinking
|
||||
# blocks at all. One-shot per outer loop.
|
||||
#
|
||||
# The strip targets ``api_messages``, which is the
|
||||
# API-call-time list that ``_build_api_kwargs`` consumes
|
||||
# on every retry. ``api_messages`` was populated once at
|
||||
# the start of the turn from shallow copies of
|
||||
# ``messages``, so mutating it does not touch the
|
||||
# canonical store. The previous implementation popped
|
||||
# ``reasoning_details`` from ``messages`` instead, which
|
||||
# had two problems: ``api_messages`` carried its own
|
||||
# reference to the field through the shallow copy, so the
|
||||
# retry's wire payload still included thinking blocks and
|
||||
# the recovery never reached the API; and the mutation
|
||||
# persisted into ``state.db`` through any subsequent
|
||||
# ``_persist_session`` call, permanently corrupting the
|
||||
# conversation. Future turns would replay the stripped
|
||||
# state, hit the same 400, and the agent would terminate
|
||||
# with ``max_retries_exhausted``, often spawning
|
||||
# cascading compaction-ended sessions chained off the
|
||||
# corrupted parent.
|
||||
if (
|
||||
classified.reason == FailoverReason.thinking_signature
|
||||
and not _retry.thinking_sig_retry_attempted
|
||||
):
|
||||
_retry.thinking_sig_retry_attempted = True
|
||||
for _m in messages:
|
||||
if isinstance(_m, dict):
|
||||
_api_stripped = 0
|
||||
for _m in api_messages:
|
||||
if isinstance(_m, dict) and "reasoning_details" in _m:
|
||||
_m.pop("reasoning_details", None)
|
||||
_api_stripped += 1
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Thinking block signature invalid — "
|
||||
f"stripped all thinking blocks, retrying...",
|
||||
f"{agent.log_prefix}⚠️ Thinking block signature invalid, "
|
||||
f"stripped reasoning_details from api_messages for retry...",
|
||||
force=True,
|
||||
)
|
||||
logger.warning(
|
||||
"%sThinking block signature recovery: stripped "
|
||||
"reasoning_details from %d messages",
|
||||
agent.log_prefix, len(messages),
|
||||
"reasoning_details from %d api_messages "
|
||||
"(canonical messages unchanged)",
|
||||
agent.log_prefix, _api_stripped,
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -858,6 +858,20 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
|
|||
return False, ""
|
||||
|
||||
|
||||
def _used_free_parallel(result: str | None) -> bool:
|
||||
"""True when a web result came from Parallel's free Search MCP.
|
||||
|
||||
Only the keyless Parallel path tags its result with ``provider="parallel"``;
|
||||
the paid REST path and every other provider omit it. Used to label the tool
|
||||
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
|
||||
the call.
|
||||
"""
|
||||
if not isinstance(result, str) or '"provider"' not in result:
|
||||
return False
|
||||
data = safe_json_loads(result)
|
||||
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
|
|
@ -895,15 +909,17 @@ def get_cute_tool_message(
|
|||
return f"{line}{failure_suffix}"
|
||||
|
||||
if tool_name == "web_search":
|
||||
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
verb = "Parallel search" if _used_free_parallel(result) else "search"
|
||||
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
if tool_name == "web_extract":
|
||||
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
|
|
|
|||
|
|
@ -549,14 +549,32 @@ def classify_api_error(
|
|||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Anthropic thinking block signature invalid (400).
|
||||
# Anthropic thinking block recovery (400). Two distinct failure modes,
|
||||
# same recovery (strip all reasoning_details and retry without thinking
|
||||
# blocks — see the thinking_signature handler in conversation_loop.py):
|
||||
# 1. Signature mismatch: a thinking block is signed against the full
|
||||
# turn content; any upstream mutation (context compression, session
|
||||
# truncation, message merging) invalidates the signature.
|
||||
# Pattern: "signature" + "thinking".
|
||||
# 2. Frozen-block mutation: Anthropic rejects any change to the
|
||||
# thinking/redacted_thinking blocks in the *latest* assistant
|
||||
# message — "`thinking` or `redacted_thinking` blocks in the latest
|
||||
# assistant message cannot be modified. These blocks must remain as
|
||||
# they were in the original response." This carries no "signature"
|
||||
# token, so the original pattern missed it and the turn hard-aborted
|
||||
# as a non-retryable client error instead of self-healing.
|
||||
# Pattern: "thinking" + ("cannot be modified" | "must remain as they were").
|
||||
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
|
||||
# provider may be "openrouter" even though the error is Anthropic-specific.
|
||||
# The message pattern ("signature" + "thinking") is unique enough.
|
||||
# The combined patterns are unique enough.
|
||||
if (
|
||||
status_code == 400
|
||||
and "signature" in error_msg
|
||||
and "thinking" in error_msg
|
||||
and (
|
||||
"signature" in error_msg
|
||||
or "cannot be modified" in error_msg
|
||||
or "must remain as they were" in error_msg
|
||||
)
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.thinking_signature,
|
||||
|
|
|
|||
|
|
@ -1118,11 +1118,12 @@ def _skill_should_show(
|
|||
def build_skills_system_prompt(
|
||||
available_tools: "set[str] | None" = None,
|
||||
available_toolsets: "set[str] | None" = None,
|
||||
hidden_categories: "frozenset[str] | None" = None,
|
||||
) -> str:
|
||||
"""Build a compact skill index for the system prompt.
|
||||
|
||||
Two-layer cache:
|
||||
1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
|
||||
1. In-process LRU dict keyed by (skills_dir, tools, toolsets, hidden)
|
||||
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
|
||||
mtime/size manifest — survives process restarts
|
||||
|
||||
|
|
@ -1132,6 +1133,12 @@ def build_skills_system_prompt(
|
|||
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
||||
are read-only — they appear in the index but new skills are always created
|
||||
in the local dir. Local skills take precedence when names collide.
|
||||
|
||||
``hidden_categories`` (e.g. from the coding posture — see
|
||||
agent/coding_context.py) prunes whole categories from the rendered index.
|
||||
Discovery-only: the snapshot stores everything, ``skills_list`` /
|
||||
``skill_view`` still reach every skill, and a footer note tells the model
|
||||
the full catalog exists.
|
||||
"""
|
||||
skills_dir = get_skills_dir()
|
||||
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
||||
|
|
@ -1156,6 +1163,7 @@ def build_skills_system_prompt(
|
|||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
_platform_hint,
|
||||
tuple(sorted(disabled)),
|
||||
tuple(sorted(hidden_categories or ())),
|
||||
)
|
||||
with _SKILLS_PROMPT_CACHE_LOCK:
|
||||
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
|
||||
|
|
@ -1289,6 +1297,26 @@ def build_skills_system_prompt(
|
|||
except Exception as e:
|
||||
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
||||
|
||||
# Posture-driven category pruning (e.g. non-coding skills while pairing on
|
||||
# code). Match on the top-level category segment so nested categories
|
||||
# ("social-media/twitter") are pruned with their parent.
|
||||
hidden_note = ""
|
||||
if hidden_categories:
|
||||
before = sum(len(v) for v in skills_by_category.values())
|
||||
skills_by_category = {
|
||||
cat: entries
|
||||
for cat, entries in skills_by_category.items()
|
||||
if cat.split("/", 1)[0] not in hidden_categories
|
||||
}
|
||||
pruned = before - sum(len(v) for v in skills_by_category.values())
|
||||
if pruned:
|
||||
hidden_note = (
|
||||
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
|
||||
"current coding context are not listed here. The full catalog "
|
||||
"is available via skills_list if the user asks for something "
|
||||
"outside this list.)"
|
||||
)
|
||||
|
||||
if not skills_by_category:
|
||||
result = ""
|
||||
else:
|
||||
|
|
@ -1337,6 +1365,7 @@ def build_skills_system_prompt(
|
|||
"</available_skills>\n"
|
||||
"\n"
|
||||
"Only proceed without loading a skill if genuinely none are relevant to the task."
|
||||
+ hidden_note
|
||||
)
|
||||
|
||||
# ── Store in LRU cache ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -191,9 +191,21 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||
)
|
||||
if toolset
|
||||
}
|
||||
# Coding posture prunes non-coding skill categories from the index
|
||||
# (discovery-only — skills_list/skill_view still reach everything).
|
||||
_hidden_cats = frozenset()
|
||||
try:
|
||||
from agent.coding_context import coding_hidden_skill_categories
|
||||
|
||||
_hidden_cats = coding_hidden_skill_categories(
|
||||
platform=agent.platform, cwd=resolve_context_cwd()
|
||||
)
|
||||
except Exception:
|
||||
_hidden_cats = frozenset()
|
||||
skills_prompt = _r.build_skills_system_prompt(
|
||||
available_tools=agent.valid_tool_names,
|
||||
available_toolsets=avail_toolsets,
|
||||
hidden_categories=_hidden_cats or None,
|
||||
)
|
||||
else:
|
||||
skills_prompt = ""
|
||||
|
|
@ -221,6 +233,26 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||
if _env_hints:
|
||||
stable_parts.append(_env_hints)
|
||||
|
||||
# Coding posture (base Hermes, any interactive coding surface in a code
|
||||
# workspace — see agent/coding_context.py). The operating brief + the live
|
||||
# git/workspace snapshot are built once here and cached for the session;
|
||||
# the snapshot is never re-probed per turn (that would break the prompt
|
||||
# cache), so the brief tells the model to re-check git before relying on it.
|
||||
if agent.valid_tool_names:
|
||||
try:
|
||||
from agent.coding_context import coding_system_blocks
|
||||
|
||||
stable_parts.extend(
|
||||
coding_system_blocks(
|
||||
platform=agent.platform,
|
||||
cwd=resolve_context_cwd(),
|
||||
model=agent.model,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Coding-context probing must never block prompt build.
|
||||
pass
|
||||
|
||||
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
|
||||
# something is non-default so the model can pick the right install
|
||||
# strategy without discovering by failure. Emits a single line; emits
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
|
||||
# ── Logging / callbacks ──────────────────────────────────────────
|
||||
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
|
||||
if not agent.quiet_mode:
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
|
||||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
|
||||
args_str = json.dumps(args, ensure_ascii=False)
|
||||
|
|
@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
if agent._should_emit_quiet_tool_messages():
|
||||
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
|
||||
agent._safe_print(f" {cute_msg}")
|
||||
elif getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
_preview_str = _multimodal_text_summary(function_result)
|
||||
if agent.verbose_logging:
|
||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
|
||||
|
|
@ -866,7 +866,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
elif function_name == "skill_manage":
|
||||
agent._iters_since_skill = 0
|
||||
|
||||
if not agent.quiet_mode:
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
args_str = json.dumps(function_args, ensure_ascii=False)
|
||||
if agent.verbose_logging:
|
||||
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
|
||||
|
|
@ -1384,7 +1384,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
# entire batch. The model sees it on the next API iteration.
|
||||
agent._apply_pending_steer_to_tool_results(messages, 1)
|
||||
|
||||
if not agent.quiet_mode:
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
if agent.verbose_logging:
|
||||
print(f" ✅ Tool {i} completed in {tool_duration:.2f}s")
|
||||
print(agent._wrap_verbose("Result: ", function_result))
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class AnthropicTransport(ProviderTransport):
|
|||
to OpenAI finish_reason, and collects reasoning_details in provider_data.
|
||||
"""
|
||||
import json
|
||||
from agent.anthropic_adapter import _to_plain_data
|
||||
from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
|
|
@ -94,14 +94,40 @@ class AnthropicTransport(ProviderTransport):
|
|||
reasoning_parts = []
|
||||
reasoning_details = []
|
||||
tool_calls = []
|
||||
# Verbatim, order-preserving copy of every content block in the turn.
|
||||
# Anthropic signs each thinking block against the turn content that
|
||||
# PRECEDES it at its position; when a turn interleaves thinking and
|
||||
# tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel
|
||||
# reasoning_details + tool_calls lists below lose that cross-type
|
||||
# ordering. Replaying the latest assistant message in the wrong order
|
||||
# invalidates the signatures -> HTTP 400 "thinking ... blocks in the
|
||||
# latest assistant message cannot be modified". Preserve the exact
|
||||
# block sequence here so the adapter can replay it unchanged. See
|
||||
# tests/agent/test_anthropic_thinking_block_order.py.
|
||||
ordered_blocks = []
|
||||
|
||||
for block in response.content:
|
||||
block_dict = _to_plain_data(block)
|
||||
clean_block = None
|
||||
if isinstance(block_dict, dict):
|
||||
# Sanitize at capture so output-only SDK fields (parsed_output,
|
||||
# caller, citations=None, …) never persist to state.db and leak
|
||||
# back as request input on replay → HTTP 400 "Extra inputs are
|
||||
# not permitted". Defence-in-depth with the replay-side sanitize.
|
||||
clean_block = _sanitize_replay_block(block_dict)
|
||||
if clean_block is not None:
|
||||
ordered_blocks.append(clean_block)
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
elif block.type == "thinking":
|
||||
reasoning_parts.append(block.thinking)
|
||||
block_dict = _to_plain_data(block)
|
||||
if isinstance(block_dict, dict):
|
||||
elif block.type in ("thinking", "redacted_thinking"):
|
||||
if block.type == "thinking":
|
||||
reasoning_parts.append(block.thinking)
|
||||
# Use the sanitized block (clean_block) for reasoning_details too,
|
||||
# since _extract_preserved_thinking_blocks replays these on the
|
||||
# non-ordered path. Falls back to raw only if sanitize dropped it.
|
||||
if isinstance(clean_block, dict):
|
||||
reasoning_details.append(clean_block)
|
||||
elif isinstance(block_dict, dict):
|
||||
reasoning_details.append(block_dict)
|
||||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
|
|
@ -130,6 +156,23 @@ class AnthropicTransport(ProviderTransport):
|
|||
provider_data = {}
|
||||
if reasoning_details:
|
||||
provider_data["reasoning_details"] = reasoning_details
|
||||
# Only worth carrying the ordered-blocks channel when the turn
|
||||
# actually interleaves signed thinking with tool_use — that's the
|
||||
# only shape the parallel lists reconstruct incorrectly. A turn that
|
||||
# is purely text, or thinking-then-tools with a single leading
|
||||
# thinking block, replays correctly without it.
|
||||
_has_signed_thinking = any(
|
||||
isinstance(b, dict)
|
||||
and b.get("type") in ("thinking", "redacted_thinking")
|
||||
and (b.get("signature") or b.get("data"))
|
||||
for b in ordered_blocks
|
||||
)
|
||||
_has_tool_use = any(
|
||||
isinstance(b, dict) and b.get("type") == "tool_use"
|
||||
for b in ordered_blocks
|
||||
)
|
||||
if _has_signed_thinking and _has_tool_use:
|
||||
provider_data["anthropic_content_blocks"] = ordered_blocks
|
||||
|
||||
return NormalizedResponse(
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,18 @@ class NormalizedResponse:
|
|||
pd = self.provider_data or {}
|
||||
return pd.get("reasoning_details")
|
||||
|
||||
@property
|
||||
def anthropic_content_blocks(self):
|
||||
"""Verbatim, order-preserving Anthropic content blocks for a turn.
|
||||
|
||||
Present only when an Anthropic turn interleaves signed thinking with
|
||||
tool_use — the one shape the parallel reasoning_details + tool_calls
|
||||
lists reconstruct in the wrong order, invalidating thinking-block
|
||||
signatures on replay. See agent/transports/anthropic.py.
|
||||
"""
|
||||
pd = self.provider_data or {}
|
||||
return pd.get("anthropic_content_blocks")
|
||||
|
||||
@property
|
||||
def codex_reasoning_items(self):
|
||||
pd = self.provider_data or {}
|
||||
|
|
|
|||
109
apps/desktop/electron/fs-read-dir.cjs
Normal file
109
apps/desktop/electron/fs-read-dir.cjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveDirectoryForIpc } = require('./hardening.cjs')
|
||||
|
||||
const FS_READDIR_STAT_CONCURRENCY = 16
|
||||
|
||||
// Always-hidden noise (covers non-git projects too; gitignore catches many of
|
||||
// these, but the project tree should keep the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function direntIsDirectory(dirent) {
|
||||
return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
|
||||
}
|
||||
|
||||
function direntIsFile(dirent) {
|
||||
return typeof dirent.isFile === 'function' && dirent.isFile()
|
||||
}
|
||||
|
||||
function direntIsSymbolicLink(dirent) {
|
||||
return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
|
||||
}
|
||||
|
||||
function shouldStatDirent(dirent) {
|
||||
if (direntIsDirectory(dirent)) return false
|
||||
|
||||
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
|
||||
}
|
||||
|
||||
async function entryForDirent(dirent, resolved, fsImpl) {
|
||||
const fullPath = path.join(resolved, dirent.name)
|
||||
let isDirectory = direntIsDirectory(dirent)
|
||||
|
||||
if (!isDirectory && shouldStatDirent(dirent)) {
|
||||
try {
|
||||
isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
|
||||
} catch {
|
||||
isDirectory = false
|
||||
}
|
||||
}
|
||||
|
||||
return { name: dirent.name, path: fullPath, isDirectory }
|
||||
}
|
||||
|
||||
async function mapWithStatConcurrency(items, mapper) {
|
||||
const results = new Array(items.length)
|
||||
let nextIndex = 0
|
||||
|
||||
async function runWorker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await mapper(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
|
||||
const workers = Array.from({ length: workerCount }, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function readDirForIpc(dirPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
|
||||
fs: fsImpl,
|
||||
purpose: 'Directory read'
|
||||
}))
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
|
||||
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
|
||||
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
|
||||
entryForDirent(dirent, resolved, fsImpl)
|
||||
)
|
||||
|
||||
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readDirForIpc
|
||||
}
|
||||
364
apps/desktop/electron/fs-read-dir.test.cjs
Normal file
364
apps/desktop/electron/fs-read-dir.test.cjs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
|
||||
}
|
||||
|
||||
function fakeDirent(name, flags = {}) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => Boolean(flags.directory),
|
||||
isFile: () => Boolean(flags.file),
|
||||
isSymbolicLink: () => Boolean(flags.symlink)
|
||||
}
|
||||
}
|
||||
|
||||
test('readDirForIpc hides noisy directories and files from the project tree', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'node_modules'))
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'target'), 'hidden file')
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
|
||||
const dirRoot = mkTmpDir()
|
||||
const fileRoot = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(dirRoot, 'node_modules'))
|
||||
fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
|
||||
fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
|
||||
fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
|
||||
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(dirRoot, { recursive: true, force: true })
|
||||
fs.rmSync(fileRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'z.txt'), 'z')
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'a.txt'), 'a')
|
||||
fs.mkdirSync(path.join(root, 'lib'))
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['lib', 'src', 'a.txt', 'z.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc accepts file URLs for directories', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(pathToFileURL(root).toString())
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc rejects Windows device paths before readdir', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
|
||||
entries: [],
|
||||
error: 'device-path'
|
||||
})
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
const result = await readDirForIpc(path.join(root, 'missing'))
|
||||
|
||||
assert.deepEqual(result, { entries: [], error: 'ENOENT' })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a symlink to a directory as a directory', async t => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const linked = result.entries.find(entry => entry.name === 'linked-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(linked?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
|
||||
if (process.platform !== 'win32') {
|
||||
t.skip('junctions are a Windows-specific symlink type')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`junction creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const junction = result.entries.find(entry => entry.name === 'junction-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(junction?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
|
||||
const root = mkTmpDir()
|
||||
const outside = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
|
||||
|
||||
const linkPath = path.join(root, 'outside-link')
|
||||
try {
|
||||
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(linkPath)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
fs.rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const statCalls = []
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('unknown-entry'),
|
||||
fakeDirent('linked-dir', { symlink: true }),
|
||||
fakeDirent('broken-link', { symlink: true }),
|
||||
fakeDirent('plain.txt', { file: true })
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(input, { fs: fsImpl })
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
statCalls.sort(),
|
||||
[path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
|
||||
)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
|
||||
{ name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
|
||||
{ name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
|
||||
{ name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
|
||||
])
|
||||
})
|
||||
|
||||
test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
|
||||
const failedName = 'entry-100'
|
||||
const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
|
||||
const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
|
||||
const statCalls = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
let releaseStats
|
||||
let markFirstStatStarted
|
||||
const statsReleased = new Promise(resolve => {
|
||||
releaseStats = resolve
|
||||
})
|
||||
const firstStatStarted = new Promise(resolve => {
|
||||
markFirstStatStarted = resolve
|
||||
})
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('node_modules', { symlink: true }),
|
||||
...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
markFirstStatStarted()
|
||||
await statsReleased
|
||||
active -= 1
|
||||
|
||||
const name = path.basename(fullPath)
|
||||
if (name === failedName) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
return { isDirectory: () => successfulDirectoryNames.has(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultPromise = readDirForIpc(input, { fs: fsImpl })
|
||||
await firstStatStarted
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
releaseStats()
|
||||
const result = await resultPromise
|
||||
|
||||
const expectedNames = [
|
||||
...names.filter(name => successfulDirectoryNames.has(name)).sort(),
|
||||
...names.filter(name => !successfulDirectoryNames.has(name)).sort()
|
||||
]
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.entries.length, names.length)
|
||||
assert.equal(statCalls.length, names.length)
|
||||
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
|
||||
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
|
||||
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
expectedNames
|
||||
)
|
||||
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
|
||||
assert.equal(
|
||||
result.entries.filter(entry => entry.isDirectory).length,
|
||||
successfulDirectoryNames.size
|
||||
)
|
||||
})
|
||||
54
apps/desktop/electron/git-root.cjs
Normal file
54
apps/desktop/electron/git-root.cjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
function findGitRoot(start, fsImpl = fs) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fsImpl.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function gitRootForIpc(startPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsImpl.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start, fsImpl)
|
||||
} catch {
|
||||
return findGitRoot(resolved, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findGitRoot,
|
||||
gitRootForIpc
|
||||
}
|
||||
40
apps/desktop/electron/git-root.test.cjs
Normal file
40
apps/desktop/electron/git-root.test.cjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
}
|
||||
|
||||
test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc(''), null)
|
||||
assert.equal(await gitRootForIpc(' '), null)
|
||||
assert.equal(await gitRootForIpc(null), null)
|
||||
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
const root = mkTmpDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
})
|
||||
|
|
@ -106,71 +106,155 @@ function sensitiveFileBlockReason(filePath) {
|
|||
return null
|
||||
}
|
||||
|
||||
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
|
||||
const raw = String(filePath || '').trim()
|
||||
function ipcPathError(code, message) {
|
||||
const error = new Error(message)
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
const raw = filePath.trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(`${purpose} failed: file path is required.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
if (raw.includes('\0')) {
|
||||
throw new Error(`${purpose} failed: file path is invalid.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
|
||||
}
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
||||
if (
|
||||
normalized.startsWith('//?/') ||
|
||||
normalized.startsWith('//./') ||
|
||||
normalized.startsWith('globalroot/device/') ||
|
||||
normalized.includes('/globalroot/device/')
|
||||
) {
|
||||
throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
const parsed = new URL(raw)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
throw new Error('not a file URL')
|
||||
}
|
||||
resolvedPath = fileURLToPath(parsed)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file URL is invalid.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
return path.resolve(resolvedPath)
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
|
||||
return path.resolve(resolvedBase, raw)
|
||||
const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
|
||||
const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
|
||||
const resolvedBase = path.resolve(safeBaseInput)
|
||||
rejectUnsafePathSyntax(resolvedBase, purpose)
|
||||
const resolvedPath = path.resolve(resolvedBase, raw)
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
|
||||
try {
|
||||
return await fsImpl.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
||||
}
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
||||
if (typeof fsImpl.promises.realpath !== 'function') {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const realPath = await fsImpl.promises.realpath(resolvedPath)
|
||||
rejectUnsafePathSyntax(realPath, purpose)
|
||||
return realPath
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function rejectSensitiveFilePath(filePath, purpose) {
|
||||
const blockReason = sensitiveFileBlockReason(filePath)
|
||||
if (blockReason) {
|
||||
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectoryForIpc(dirPath, options = {}) {
|
||||
const purpose = String(options.purpose || 'Directory read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
const blockReason = sensitiveFileBlockReason(resolvedPath)
|
||||
if (blockReason) {
|
||||
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
rejectSensitiveFilePath(resolvedPath, purpose)
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`${purpose} failed: file does not exist.`)
|
||||
}
|
||||
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${purpose} failed: path points to a directory.`)
|
||||
throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${purpose} failed: only regular files can be read.`)
|
||||
throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
if (options.blockSensitive !== false) {
|
||||
rejectSensitiveFilePath(realPath, purpose)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file is not readable.`)
|
||||
throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
|
||||
}
|
||||
|
||||
return { resolvedPath, stat }
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
@ -178,7 +262,10 @@ module.exports = {
|
|||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
rejectUnsafePathSyntax,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,20 @@ const { pathToFileURL } = require('node:url')
|
|||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, error => {
|
||||
assert.equal(error?.code, code)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
|
@ -51,6 +60,52 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
|||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
|
||||
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
|
||||
|
||||
const devicePaths = [
|
||||
'\\\\?\\C:\\secret.txt',
|
||||
'\\\\.\\C:\\secret.txt',
|
||||
'\\\\?\\UNC\\server\\share\\secret.txt',
|
||||
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
|
||||
]
|
||||
|
||||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
|
||||
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
|
||||
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('notes.txt', {
|
||||
baseDir: ` ${baseDir} `,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
path.resolve(baseDir, 'notes.txt')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
|
@ -71,6 +126,13 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
|||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
|
|
@ -114,3 +176,91 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
|||
})
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const http = require('node:http')
|
|||
const https = require('node:https')
|
||||
const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
|
|
@ -31,6 +31,12 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
|||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
|
|
@ -61,6 +67,7 @@ const {
|
|||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
|
|
@ -726,7 +733,7 @@ function openExternalUrl(rawUrl) {
|
|||
if (parsed.protocol === 'file:') {
|
||||
let localPath
|
||||
try {
|
||||
localPath = fileURLToPath(parsed.toString())
|
||||
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
|
@ -1312,6 +1319,11 @@ function runGit(args, options = {}) {
|
|||
|
||||
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
|
||||
|
||||
async function getOriginUrl(updateRoot) {
|
||||
const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot })
|
||||
return origin.code === 0 ? origin.stdout.trim() : ''
|
||||
}
|
||||
|
||||
function emitUpdateProgress(payload) {
|
||||
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
|
||||
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
|
||||
|
|
@ -1331,7 +1343,9 @@ async function resolveHealedBranch(updateRoot, branch) {
|
|||
return branch || 'main'
|
||||
}
|
||||
|
||||
const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
|
||||
const originUrl = await getOriginUrl(updateRoot)
|
||||
const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin'
|
||||
const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot })
|
||||
if (probe.code !== 2) {
|
||||
return branch
|
||||
}
|
||||
|
|
@ -1359,6 +1373,40 @@ async function checkUpdates() {
|
|||
}
|
||||
|
||||
branch = await resolveHealedBranch(updateRoot, branch)
|
||||
const originUrl = await getOriginUrl(updateRoot)
|
||||
if (isOfficialSshRemote(originUrl)) {
|
||||
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
||||
const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
|
||||
git(['rev-parse', 'HEAD']),
|
||||
runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
|
||||
git(['status', '--porcelain']),
|
||||
git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
])
|
||||
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
|
||||
if (target.code !== 0 || !targetSha) {
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
error: 'fetch-failed',
|
||||
message: firstLine(target.stderr) || 'git ls-remote failed.',
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
currentBranch,
|
||||
behind: currentSha && currentSha === targetSha ? 0 : 1,
|
||||
currentSha,
|
||||
targetSha,
|
||||
commits: [],
|
||||
dirty: dirtyStr.length > 0,
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
||||
if (fetched.code !== 0) {
|
||||
return {
|
||||
|
|
@ -2833,10 +2881,10 @@ async function resourceBufferFromUrl(rawUrl) {
|
|||
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
||||
return { buffer, mimeType }
|
||||
}
|
||||
if (rawUrl.startsWith('file:')) {
|
||||
const filePath = fileURLToPath(rawUrl)
|
||||
const buffer = await fs.promises.readFile(filePath)
|
||||
return { buffer, mimeType: mimeTypeForPath(filePath) }
|
||||
if (/^file:/i.test(rawUrl)) {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
|
||||
const buffer = await fs.promises.readFile(resolvedPath)
|
||||
return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
|
||||
}
|
||||
|
||||
const parsed = new URL(rawUrl)
|
||||
|
|
@ -2914,11 +2962,13 @@ function expandUserPath(filePath) {
|
|||
return value
|
||||
}
|
||||
|
||||
function previewFileTarget(rawTarget, baseDir) {
|
||||
async function previewFileTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
|
||||
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
|
||||
let resolved = filePath
|
||||
let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), {
|
||||
baseDir: base,
|
||||
purpose: 'Preview target'
|
||||
})
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
resolved = path.join(resolved, 'index.html')
|
||||
|
|
@ -2929,6 +2979,8 @@ function previewFileTarget(rawTarget, baseDir) {
|
|||
return null
|
||||
}
|
||||
|
||||
;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' }))
|
||||
|
||||
const mimeType = mimeTypeForPath(resolved)
|
||||
const metadata = previewFileMetadata(resolved, mimeType)
|
||||
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
|
||||
|
|
@ -2974,7 +3026,7 @@ function previewUrlTarget(rawTarget) {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
async function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
|
|
@ -2986,20 +3038,15 @@ function normalizePreviewTarget(rawTarget, baseDir) {
|
|||
return previewUrlTarget(raw)
|
||||
}
|
||||
|
||||
return previewFileTarget(raw, baseDir)
|
||||
return await previewFileTarget(raw, baseDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function filePathFromPreviewUrl(rawUrl) {
|
||||
const filePath = fileURLToPath(String(rawUrl || ''))
|
||||
|
||||
if (!fileExists(filePath)) {
|
||||
throw new Error('Preview file is not readable')
|
||||
}
|
||||
|
||||
return filePath
|
||||
async function filePathFromPreviewUrl(rawUrl) {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' })
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
function sendPreviewFileChanged(payload) {
|
||||
|
|
@ -3009,8 +3056,8 @@ function sendPreviewFileChanged(payload) {
|
|||
webContents.send('hermes:preview-file-changed', payload)
|
||||
}
|
||||
|
||||
function watchPreviewFile(rawUrl) {
|
||||
const filePath = filePathFromPreviewUrl(rawUrl)
|
||||
async function watchPreviewFile(rawUrl) {
|
||||
const filePath = await filePathFromPreviewUrl(rawUrl)
|
||||
const watchDir = path.dirname(filePath)
|
||||
const targetName = path.basename(filePath)
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
|
|
@ -5542,48 +5589,6 @@ ipcMain.handle('hermes:logs:reveal', async () => {
|
|||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
|
||||
// Always-hidden noise (covers non-git projects too — gitignore would catch
|
||||
// these anyway when present, but we want the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function findGitRoot(start) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath) {
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return false
|
||||
|
|
@ -5766,46 +5771,9 @@ function disposeTerminalSession(id) {
|
|||
return true
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
|
||||
const resolved = path.resolve(String(dirPath || ''))
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
if (!resolved) {
|
||||
return { entries: [], error: 'invalid-path' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
|
||||
|
||||
const entries = dirents
|
||||
.filter(d => {
|
||||
if (FS_READDIR_HIDDEN.has(d.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
|
||||
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
|
||||
const input = String(startPath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start)
|
||||
} catch {
|
||||
return findGitRoot(resolved)
|
||||
}
|
||||
})
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
|
|
|
|||
56
apps/desktop/electron/update-remote.cjs
Normal file
56
apps/desktop/electron/update-remote.cjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Pure helpers for choosing a remote URL during passive update checks.
|
||||
*
|
||||
* A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`.
|
||||
* If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch
|
||||
* origin` triggers an unexplained hardware-touch prompt. For passive checks
|
||||
* against the official repo we substitute the public HTTPS `ls-remote` path,
|
||||
* which needs no auth and cannot prompt. Active update/apply flows are left
|
||||
* unchanged.
|
||||
*
|
||||
* Extracted from main.cjs so the security-critical remote detection is unit
|
||||
* testable without booting Electron (main.cjs requires('electron') at load).
|
||||
*/
|
||||
|
||||
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
|
||||
const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
|
||||
|
||||
// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased,
|
||||
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
|
||||
// compare equal.
|
||||
function canonicalGitHubRemote(url) {
|
||||
if (!url) return ''
|
||||
let value = String(url).trim()
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
value = `github.com/${value.slice('git@github.com:'.length)}`
|
||||
} else if (value.startsWith('ssh://git@github.com/')) {
|
||||
value = `github.com/${value.slice('ssh://git@github.com/'.length)}`
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
|
||||
} catch {
|
||||
// Leave non-URL forms unchanged.
|
||||
}
|
||||
}
|
||||
value = value.trim().replace(/\/+$/, '')
|
||||
if (value.endsWith('.git')) value = value.slice(0, -4)
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
function isSshRemote(url) {
|
||||
const value = String(url || '').trim().toLowerCase()
|
||||
return value.startsWith('git@') || value.startsWith('ssh://')
|
||||
}
|
||||
|
||||
function isOfficialSshRemote(url) {
|
||||
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
canonicalGitHubRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
}
|
||||
78
apps/desktop/electron/update-remote.test.cjs
Normal file
78
apps/desktop/electron/update-remote.test.cjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Tests for electron/update-remote.cjs — the remote-detection helpers that
|
||||
* keep passive update checks off the SSH origin for official installs.
|
||||
*
|
||||
* Run with: node --test electron/update-remote.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Why this matters: a public install can carry
|
||||
* origin=git@github.com:NousResearch/hermes-agent.git. A background
|
||||
* `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey
|
||||
* key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote
|
||||
* must reliably recognize the official SSH remote (in every URL form,
|
||||
* case-insensitively) so the caller can swap in the anonymous HTTPS path —
|
||||
* while NOT misclassifying forks, other hosts, or the HTTPS remote (which
|
||||
* never prompts and should keep the normal fetch path).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
canonicalGitHubRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
|
||||
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
// Case-insensitive: an uppercased owner still canonicalizes to the same repo.
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
// Trailing slashes are stripped.
|
||||
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL)
|
||||
})
|
||||
|
||||
test('canonicalGitHubRemote is empty for falsy input', () => {
|
||||
assert.equal(canonicalGitHubRemote(''), '')
|
||||
assert.equal(canonicalGitHubRemote(null), '')
|
||||
assert.equal(canonicalGitHubRemote(undefined), '')
|
||||
})
|
||||
|
||||
test('isSshRemote detects scp-like and ssh:// forms only', () => {
|
||||
assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||
assert.equal(isSshRemote(''), false)
|
||||
assert.equal(isSshRemote(null), false)
|
||||
})
|
||||
|
||||
test('isOfficialSshRemote is true only for the official repo over SSH', () => {
|
||||
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true)
|
||||
assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||
// Case-insensitive owner/repo match.
|
||||
assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true)
|
||||
})
|
||||
|
||||
test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => {
|
||||
// A fork over SSH belongs to the user — fetching it is their own remote,
|
||||
// not the official upstream, so the SSH-avoidance swap must not apply.
|
||||
assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false)
|
||||
// Same repo name on a different host is not the official repo.
|
||||
assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false)
|
||||
// HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the
|
||||
// normal fetch path — must not be flagged as an official SSH remote.
|
||||
assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||
assert.equal(isOfficialSshRemote(''), false)
|
||||
assert.equal(isOfficialSshRemote(null), false)
|
||||
})
|
||||
|
||||
test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => {
|
||||
// Invariant: the URL we substitute in must be the same repo we detect.
|
||||
assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL)
|
||||
})
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
|
|
@ -3,32 +3,25 @@ import { ComposerPrimitive } from '@assistant-ui/react'
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_ROW_CLASS = [
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
|
||||
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
|
||||
'hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
ariaLabel,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ export interface CompletionEntry {
|
|||
text: string
|
||||
display?: unknown
|
||||
meta?: unknown
|
||||
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
|
||||
* header whenever this changes between consecutive items, so the fetcher must
|
||||
* emit entries already grouped contiguously. */
|
||||
group?: string
|
||||
/** Optional completion-action id. When set, picking the item runs that action
|
||||
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
|
||||
action?: string
|
||||
}
|
||||
|
||||
export interface CompletionPayload {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
desktopSkinSlashCompletions,
|
||||
desktopSlashDescription,
|
||||
type DesktopThemeCommandOption,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
|
@ -16,7 +21,10 @@ interface SlashItemMetadata extends Record<string, string> {
|
|||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
group: string
|
||||
rawText: string
|
||||
/** Completion-action id; empty for ordinary insert-a-chip completions. */
|
||||
action: string
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
|
|
@ -38,12 +46,21 @@ function commandText(value: string): string {
|
|||
return value.startsWith('/') ? value : `/${value}`
|
||||
}
|
||||
|
||||
/** How many recent sessions to surface inline before the "Browse all…" entry. */
|
||||
const SESSION_INLINE_LIMIT = 7
|
||||
|
||||
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
||||
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
|
||||
export function useSlashCompletions(options: {
|
||||
gateway: HermesGateway | null
|
||||
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
|
||||
* come from here, not the backend (whose skin list is CLI/TUI-only). */
|
||||
skinThemes?: DesktopThemeCommandOption[]
|
||||
activeSkin?: string
|
||||
}): {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
loading: boolean
|
||||
} {
|
||||
const { gateway } = options
|
||||
const { gateway, skinThemes, activeSkin } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
const fetcher = useCallback(
|
||||
|
|
@ -54,34 +71,136 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||
|
||||
const text = `/${query}`
|
||||
|
||||
// The desktop owns /skin entirely (client-side theme context). Surface its
|
||||
// theme list inside this single popover instead of a bespoke one, and skip
|
||||
// the backend skin completions (which describe CLI/TUI skins that don't
|
||||
// apply here). Matches once we're past `/skin ` into the arg stage.
|
||||
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
|
||||
|
||||
if (skinArg && skinThemes) {
|
||||
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
|
||||
text: entry.text,
|
||||
display: entry.display,
|
||||
meta: entry.meta,
|
||||
group: 'Themes'
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
// /resume (and its aliases) completes recent sessions inline — the same
|
||||
// client-side list the picker overlay shows — instead of the backend
|
||||
// (whose /resume opens an interactive TUI picker we can't render here).
|
||||
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
|
||||
|
||||
if (sessionArg) {
|
||||
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
|
||||
|
||||
const matches = (
|
||||
needle
|
||||
? $sessions.get().filter(
|
||||
session =>
|
||||
sessionTitle(session).toLowerCase().includes(needle) ||
|
||||
(session.preview ?? '').toLowerCase().includes(needle) ||
|
||||
session.id.toLowerCase().includes(needle)
|
||||
)
|
||||
: $sessions.get()
|
||||
).slice(0, SESSION_INLINE_LIMIT)
|
||||
|
||||
const items: CompletionEntry[] = matches.map(session => ({
|
||||
text: `/resume ${session.id}`,
|
||||
display: sessionTitle(session),
|
||||
meta: (session.preview ?? '').trim(),
|
||||
group: 'Sessions'
|
||||
}))
|
||||
|
||||
// Trailing "more" affordance (Cursor-style): picking it opens the full
|
||||
// session picker overlay directly. `text` stays a bare `/resume` so that
|
||||
// submitting it (Enter) still opens the overlay if the action is skipped.
|
||||
items.push({
|
||||
text: '/resume',
|
||||
display: 'Browse all sessions…',
|
||||
meta: '',
|
||||
group: 'Sessions',
|
||||
action: 'session-picker'
|
||||
})
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!query) {
|
||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
|
||||
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
meta
|
||||
}))
|
||||
// Prefer the categorized layout so the popover renders section headers
|
||||
// (Session, Tools & Skills, ...). Fall back to the flat list when the
|
||||
// backend didn't categorize.
|
||||
const sections = catalog.categories?.length
|
||||
? catalog.categories
|
||||
: [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const items = sections.flatMap(section =>
|
||||
section.pairs.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
group: section.name || undefined,
|
||||
meta
|
||||
}))
|
||||
)
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
|
||||
'complete.slash',
|
||||
{ text }
|
||||
)
|
||||
|
||||
const items = (result.items ?? [])
|
||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
||||
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||
// with replace_from = 14. Rewrite those entries so the popover inserts
|
||||
// the full `/personality alice` token instead of stranding `/alice`.
|
||||
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
|
||||
const isArgCompletion = replaceFrom > 1
|
||||
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
|
||||
|
||||
const decorated = (result.items ?? [])
|
||||
.map(item => {
|
||||
if (!isArgCompletion) {
|
||||
return item
|
||||
}
|
||||
|
||||
const argText = typeof item.text === 'string' ? item.text : ''
|
||||
|
||||
return { ...item, text: `${prefix}${argText}` }
|
||||
})
|
||||
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
|
||||
.map(item => ({
|
||||
...item,
|
||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
||||
// Arg suggestions (e.g. `/handoff <platform>`) live under one
|
||||
// header; otherwise split skills out from built-in commands.
|
||||
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
|
||||
// Arg items carry their own meta (the personality/toolset/platform
|
||||
// blurb). Only command rows get the registry description — looking
|
||||
// one up for `/personality none` would clobber it with the parent
|
||||
// command's text.
|
||||
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
|
||||
}))
|
||||
|
||||
// Keep each group contiguous so headers render once: Commands before
|
||||
// Skills (stable within a group, preserving backend relevance order).
|
||||
const groupOrder = ['Commands', 'Skills', 'Options']
|
||||
|
||||
const items = isArgCompletion
|
||||
? decorated
|
||||
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
}
|
||||
},
|
||||
[gateway]
|
||||
[gateway, skinThemes, activeSkin]
|
||||
)
|
||||
|
||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||
|
|
@ -93,6 +212,8 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||
command,
|
||||
display,
|
||||
meta,
|
||||
group: textValue(entry.group),
|
||||
action: textValue(entry.action),
|
||||
// Provide rawText so hermesDirectiveFormatter.serialize uses the
|
||||
// direct-insertion path instead of the legacy @type:id fallback.
|
||||
// Without this, the item.id (which includes a "|index" suffix for
|
||||
|
|
|
|||
|
|
@ -13,17 +13,25 @@ import {
|
|||
useState
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
|
|
@ -40,8 +48,9 @@ import {
|
|||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
|
||||
|
||||
|
|
@ -74,9 +83,9 @@ import {
|
|||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
|
|
@ -95,6 +104,30 @@ const COMPOSER_FADE_BACKGROUND =
|
|||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
/** Completion items can carry an `action` (set in use-slash-completions) that
|
||||
* runs a side effect on pick instead of inserting a chip — e.g. the session
|
||||
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
|
||||
* items are a registry row, not a composer branch. */
|
||||
const COMPLETION_ACTIONS: Record<string, () => void> = {
|
||||
'session-picker': () => setSessionPickerOpen(true)
|
||||
}
|
||||
|
||||
/** Map a picked `/` completion to its pill accent. Driven by the completion
|
||||
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
|
||||
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
const group = (item.metadata as { group?: unknown } | undefined)?.group
|
||||
|
||||
if (group === 'Skills') {
|
||||
return 'skill'
|
||||
}
|
||||
|
||||
if (group === 'Themes') {
|
||||
return 'theme'
|
||||
}
|
||||
|
||||
return 'command'
|
||||
}
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
|
|
@ -104,6 +137,10 @@ interface QueueEditState {
|
|||
|
||||
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
||||
|
||||
// Quiet period after the last keystroke before persisting the draft;
|
||||
// unmount/pagehide flushes bypass it.
|
||||
const DRAFT_PERSIST_DEBOUNCE_MS = 400
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
|
|
@ -145,6 +182,9 @@ export function ChatBar({
|
|||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||
const drainingQueueRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
|
|
@ -156,14 +196,17 @@ export function ChatBar({
|
|||
const [dragActive, setDragActive] = useState(false)
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const queueEditRef = useRef(queueEdit)
|
||||
queueEditRef.current = queueEdit
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
|
||||
|
||||
const stacked = expanded || narrow || tight
|
||||
const trimmedDraft = draft.trim()
|
||||
|
|
@ -171,10 +214,12 @@ export function ChatBar({
|
|||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer =
|
||||
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
||||
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -462,12 +507,6 @@ export function ChatBar({
|
|||
})
|
||||
}, [])
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
|
|
@ -620,16 +659,50 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Action items (e.g. "Browse all sessions…") run a side effect instead of
|
||||
// inserting a chip: strip the typed trigger token, then fire the action.
|
||||
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
|
||||
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
|
||||
|
||||
if (runAction) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, prefix)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
closeTrigger()
|
||||
runAction()
|
||||
requestMainFocus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
|
||||
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||
// it — expand to its options step so the popover shows the inline list, just
|
||||
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||
// already an arg pick (`/personality alice`), so it commits normally.
|
||||
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||
|
||||
const expandsToArgs =
|
||||
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
// No pill while expanding — the bare command stays plain text until an arg
|
||||
// is picked, at which point a single pill is emitted for the full command.
|
||||
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
|
||||
const keepTriggerOpen = starter || expandsToArgs
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestMainFocus()
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
const sel = window.getSelection()
|
||||
|
|
@ -639,7 +712,20 @@ export function ChatBar({
|
|||
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
if (slashKind) {
|
||||
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
|
||||
// the platform) land here because the caret sits past a contenteditable
|
||||
// chip. Rebuild the prefix and re-emit a single pill for the full command.
|
||||
renderComposerContents(editor, prefix)
|
||||
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
renderComposerContents(editor, `${prefix}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
|
|
@ -650,8 +736,13 @@ export function ChatBar({
|
|||
replaceRange.setEnd(node, offset)
|
||||
replaceRange.deleteContents()
|
||||
|
||||
if (directive) {
|
||||
const chip = refChipElement(directive[1], directive[2])
|
||||
const chip = slashKind
|
||||
? slashChipElement(serialized, slashKind)
|
||||
: directive
|
||||
? refChipElement(directive[1], directive[2])
|
||||
: null
|
||||
|
||||
if (chip) {
|
||||
const space = document.createTextNode(' ')
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(chip, space)
|
||||
|
|
@ -1022,6 +1113,69 @@ export function ChatBar({
|
|||
}
|
||||
}
|
||||
|
||||
const stashAt = (
|
||||
scope: string | null,
|
||||
text = draftRef.current,
|
||||
attachments = $composerAttachments.get()
|
||||
) => stashSessionDraft(scope, text, attachments)
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
// on enter. Keyed writes are idempotent, so no skip-sentinel.
|
||||
useEffect(() => {
|
||||
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
|
||||
loadIntoComposer(text, attachments)
|
||||
|
||||
return () => {
|
||||
const editing = queueEditRef.current
|
||||
|
||||
if (editing?.sessionKey === activeQueueSessionKey) {
|
||||
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
|
||||
} else if (!isBrowsingHistory(sessionId)) {
|
||||
stashAt(activeQueueSessionKey)
|
||||
}
|
||||
}
|
||||
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Debounced stash into the active scope. Skipped while browsing history or
|
||||
// editing a queued prompt — recalled text must not clobber the real draft.
|
||||
useEffect(() => {
|
||||
if (isBrowsingHistory(sessionId) || queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(activeQueueSessionKey, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
|
||||
|
||||
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
|
||||
// inside the debounce window would drop trailing keystrokes without this.
|
||||
useEffect(() => {
|
||||
const flushPendingDraftPersist = () => {
|
||||
const pending = pendingDraftPersistRef.current
|
||||
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(pending.scope, pending.text)
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', flushPendingDraftPersist)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flushPendingDraftPersist)
|
||||
flushPendingDraftPersist()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||
if (!activeQueueSessionKey || queueEdit) {
|
||||
return
|
||||
|
|
@ -1224,20 +1378,38 @@ export function ChatBar({
|
|||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
useEffect(() => {
|
||||
if (!queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
|
||||
return
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey) {
|
||||
if (editingQueuedPrompt) {
|
||||
return
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
setQueueEdit(null)
|
||||
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||
const submittedScope = activeQueueSessionKeyRef.current
|
||||
const submittedAttachments = attachments ?? []
|
||||
|
||||
const restore = () => {
|
||||
loadIntoComposer(text, submittedAttachments)
|
||||
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
|
||||
}
|
||||
|
||||
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
|
||||
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
|
||||
.catch(restore)
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
|
|
@ -1248,8 +1420,10 @@ export function ChatBar({
|
|||
// input event; refresh it from the editor once more to also cover an
|
||||
// in-flight keystroke that hasn't fired its input event yet.
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
aui.composer().setText(domText)
|
||||
|
|
@ -1270,10 +1444,9 @@ export function ChatBar({
|
|||
// /send directives). Queuing them would make every slash command wait
|
||||
// for the current turn to finish, which is how the TUI never behaves.
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
|
||||
const submitted = text
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
void onSubmit(submitted)
|
||||
dispatchSubmit(text)
|
||||
} else if (payloadPresent) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
|
|
@ -1285,12 +1458,12 @@ export function ChatBar({
|
|||
} else if (!payloadPresent && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
} else if (payloadPresent) {
|
||||
const submitted = text
|
||||
const submittedAttachments = cloneAttachments(attachments)
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
dispatchSubmit(text, submittedAttachments)
|
||||
}
|
||||
|
||||
focusInput()
|
||||
|
|
@ -1515,7 +1688,6 @@ export function ChatBar({
|
|||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
DIRECTIVE_CHIP_CLASS,
|
||||
directiveIconElement,
|
||||
directiveIconSvg,
|
||||
formatRefValue
|
||||
formatRefValue,
|
||||
slashChipClass,
|
||||
type SlashChipKind,
|
||||
slashIconElement
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
|
@ -77,6 +80,24 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
|
|||
return chip
|
||||
}
|
||||
|
||||
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
|
||||
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
|
||||
* it back to the exact text that gets submitted. */
|
||||
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
|
||||
const chip = document.createElement('span')
|
||||
const text = document.createElement('span')
|
||||
|
||||
chip.contentEditable = 'false'
|
||||
chip.dataset.refText = command
|
||||
chip.dataset.slashKind = kind
|
||||
chip.className = slashChipClass(kind)
|
||||
text.className = 'truncate'
|
||||
text.textContent = label || command
|
||||
chip.append(slashIconElement(kind), text)
|
||||
|
||||
return chip
|
||||
}
|
||||
|
||||
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
|
||||
const lines = text.split('\n')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
interface SkinSlashPopoverProps {
|
||||
draft: string
|
||||
onSelect: (command: string) => void
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={c.themeSuggestions}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
role="listbox"
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<button
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
key={item.text}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onSelect(item.text)
|
||||
}}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,6 +22,33 @@ describe('detectTrigger', () => {
|
|||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the slash trigger live while typing args', () => {
|
||||
expect(detectTrigger('/personality ')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality ',
|
||||
tokenLength: 13
|
||||
})
|
||||
expect(detectTrigger('/personality alic')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
tokenLength: 17
|
||||
})
|
||||
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||
kind: '/',
|
||||
query: 'tools enable foo',
|
||||
tokenLength: 17
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat file-style paths as slash triggers', () => {
|
||||
expect(detectTrigger('src/foo/bar')).toBeNull()
|
||||
expect(detectTrigger('/path/to/file')).toBeNull()
|
||||
})
|
||||
|
||||
it('still anchors at-mention triggers strictly at the token edge', () => {
|
||||
expect(detectTrigger('@file:path with space')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractClipboardImageBlobs', () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ export interface TriggerState {
|
|||
tokenLength: number
|
||||
}
|
||||
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||
// single tokens. `/` triggers keep going so the popover stays live while the
|
||||
// user types args (`/personality alic` → arg completer suggests `alice`).
|
||||
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
|
||||
// paths like `src/foo/bar`.
|
||||
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
|
|
@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
const slash = SLASH_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
if (slash) {
|
||||
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
|
||||
}
|
||||
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (at) {
|
||||
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,17 @@ describe('ComposerTriggerPopover i18n', () => {
|
|||
})
|
||||
|
||||
it('renders localized loading copy for slash commands', () => {
|
||||
const { container } = renderPopover('/', true)
|
||||
renderPopover('/', true)
|
||||
|
||||
// While loading the popover shows only the spinner + loading copy — the
|
||||
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
|
||||
expect(screen.getByText('查找中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the slash empty-state hint when not loading', () => {
|
||||
const { container } = renderPopover('/')
|
||||
|
||||
expect(screen.getByText('没有匹配项。')).toBeTruthy()
|
||||
expect(container.textContent).toContain('/help')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -7,7 +9,6 @@ import { cn } from '@/lib/utils'
|
|||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
|
||||
|
|
@ -23,11 +24,7 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
|
|||
url: 'globe'
|
||||
}
|
||||
|
||||
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
||||
if (kind === '/') {
|
||||
return 'terminal'
|
||||
}
|
||||
|
||||
function atIcon(item: Unstable_TriggerItem) {
|
||||
const meta = item.metadata as { rawText?: string } | undefined
|
||||
const raw = meta?.rawText || item.label
|
||||
|
||||
|
|
@ -42,6 +39,18 @@ function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
|||
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
||||
}
|
||||
|
||||
interface RowMeta {
|
||||
display?: string
|
||||
group?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
const ROW_BASE_CLASS = [
|
||||
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
|
||||
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
|
|
@ -63,6 +72,9 @@ export function ComposerTriggerPopover({
|
|||
}: ComposerTriggerPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.composer
|
||||
const isSlash = kind === '/'
|
||||
|
||||
let lastGroup: string | undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -73,41 +85,94 @@ export function ComposerTriggerPopover({
|
|||
role="listbox"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
)
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
|
||||
const meta = item.metadata as RowMeta | undefined
|
||||
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
|
||||
const description = meta?.meta || item.description
|
||||
const group = meta?.group?.trim()
|
||||
const showHeader = isSlash && Boolean(group) && group !== lastGroup
|
||||
const isFirstHeader = lastGroup === undefined
|
||||
lastGroup = group || lastGroup
|
||||
const active = index === activeIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
<Fragment key={item.id}>
|
||||
{showHeader && (
|
||||
<div
|
||||
className={cn(
|
||||
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
|
||||
isFirstHeader ? 'pt-0.5' : 'pt-2'
|
||||
)}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
{isSlash ? (
|
||||
<>
|
||||
{/* Active row (keyboard nav or hover) un-truncates inline so
|
||||
long command names / descriptions stay readable without a
|
||||
floating tooltip. */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.8125rem] font-medium leading-snug text-foreground',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={atIcon(item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ import { RightSidebarPane } from './right-sidebar'
|
|||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { SessionPickerOverlay } from './session-picker-overlay'
|
||||
import { SessionSwitcher } from './session-switcher'
|
||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||
|
|
@ -694,6 +695,7 @@ export function DesktopController() {
|
|||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
|
|
@ -829,6 +831,7 @@ export function DesktopController() {
|
|||
/>
|
||||
)}
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<SessionPickerOverlay onResume={resumeSession} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
|
|
|
|||
100
apps/desktop/src/app/right-sidebar/files/ipc.test.ts
Normal file
100
apps/desktop/src/app/right-sidebar/files/ipc.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/// <reference types="node" />
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const readFileDataUrl = vi.fn<(path: string) => Promise<string>>()
|
||||
const gitRoot = vi.fn<(path: string) => Promise<string | null>>()
|
||||
|
||||
function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function dataUrl(text: string) {
|
||||
return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
;(
|
||||
window as unknown as {
|
||||
hermesDesktop: {
|
||||
gitRoot: typeof gitRoot
|
||||
readDir: typeof readDir
|
||||
readFileDataUrl: typeof readFileDataUrl
|
||||
}
|
||||
}
|
||||
).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
}
|
||||
|
||||
describe('readProjectDir', () => {
|
||||
beforeEach(() => {
|
||||
clearProjectDirCache()
|
||||
readDir.mockReset()
|
||||
readFileDataUrl.mockReset()
|
||||
gitRoot.mockReset()
|
||||
installBridge()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearProjectDirCache()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('returns no-bridge when the desktop bridge is unavailable', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
|
||||
})
|
||||
|
||||
it('filters gitignored entries when readDir returns Windows-style paths', async () => {
|
||||
gitRoot.mockResolvedValue('C:\\repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === 'C:\\repo\\src') {
|
||||
return ok([
|
||||
{ name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
|
||||
{ name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
|
||||
{ name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
|
||||
])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo') {
|
||||
return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo/src') {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
|
||||
|
||||
const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
|
||||
expect(gitRoot).toHaveBeenCalledWith('C:/repo')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
|
||||
})
|
||||
|
||||
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
|
||||
gitRoot.mockResolvedValue('/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo/src') {
|
||||
return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
|
||||
const result = await readProjectDir('/repo/src', '/repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -27,7 +27,7 @@ function decodeDataUrl(dataUrl: string) {
|
|||
}
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ function ProjectTreeRow({
|
|||
}
|
||||
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
const isPlaceholder = Boolean(node.data.placeholder)
|
||||
const isErrorPlaceholder = node.data.placeholder === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -210,8 +211,10 @@ function ProjectTreeRow({
|
|||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder ? (
|
||||
{isPlaceholder && !isErrorPlaceholder ? (
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
) : isErrorPlaceholder ? (
|
||||
<Codicon name="warning" size="0.75rem" />
|
||||
) : isFolder ? (
|
||||
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ describe('useProjectTree', () => {
|
|||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
|
||||
it('captures per-folder error code and shows an error placeholder child', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
|
|
@ -119,7 +119,14 @@ describe('useProjectTree', () => {
|
|||
})
|
||||
|
||||
expect(result.current.data[0].error).toBe('EACCES')
|
||||
expect(result.current.data[0].children).toEqual([])
|
||||
expect(result.current.data[0].children).toEqual([
|
||||
{
|
||||
id: '/p/priv::__error__',
|
||||
isDirectory: false,
|
||||
name: 'Unable to read (EACCES)',
|
||||
placeholder: 'error'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes concurrent loadChildren calls for the same id', async () => {
|
||||
|
|
|
|||
|
|
@ -14,11 +14,14 @@ export interface TreeNode {
|
|||
children?: TreeNode[]
|
||||
/** True while a readDir for this folder is in flight. */
|
||||
loading?: boolean
|
||||
/** Synthetic loading/error rows are not real filesystem entries. */
|
||||
placeholder?: 'error' | 'loading'
|
||||
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ID = '__loading__'
|
||||
const ERROR_PLACEHOLDER_ID = '__error__'
|
||||
|
||||
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
|
||||
return { id: path, isDirectory, name }
|
||||
|
|
@ -43,7 +46,16 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
|
|||
}
|
||||
|
||||
function placeholderChild(parentId: string): TreeNode {
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
|
||||
}
|
||||
|
||||
function errorChild(parentId: string, error: string | undefined): TreeNode {
|
||||
return {
|
||||
id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
|
||||
isDirectory: false,
|
||||
name: `Unable to read (${error || 'read-error'})`,
|
||||
placeholder: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
|
|
@ -227,7 +239,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
|||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
32
apps/desktop/src/app/session-picker-overlay.tsx
Normal file
32
apps/desktop/src/app/session-picker-overlay.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { SessionPickerDialog } from '@/components/session-picker'
|
||||
import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session'
|
||||
|
||||
interface SessionPickerOverlayProps {
|
||||
onResume: (storedSessionId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens —
|
||||
* the desktop equivalent of the TUI's sessions overlay. Resuming runs through
|
||||
* the same `resumeSession` path the sidebar uses.
|
||||
*/
|
||||
export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) {
|
||||
const open = useStore($sessionPickerOpen)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const activeStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionPickerDialog
|
||||
activeStoredSessionId={activeStoredSessionId}
|
||||
onOpenChange={setSessionPickerOpen}
|
||||
onResume={onResume}
|
||||
open={open}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -64,6 +64,67 @@ interface QueuedStreamDeltas {
|
|||
reasoning: string
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
|
||||
const patch: SessionRuntimeStatePatch = {}
|
||||
|
||||
if (typeof payload?.model === 'string') {
|
||||
patch.model = payload.model || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.provider === 'string') {
|
||||
patch.provider = payload.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
patch.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
patch.branch = payload.branch
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
patch.personality = normalizePersonalityValue(payload.personality)
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
patch.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
patch.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
patch.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
patch.yolo = payload.yolo
|
||||
}
|
||||
|
||||
return patch
|
||||
}
|
||||
|
||||
function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
|
||||
return Object.keys(patch).length > 0
|
||||
}
|
||||
|
||||
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
|
||||
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
|
||||
// token got its own React commit + Streamdown markdown re-parse, scaling
|
||||
|
|
@ -628,36 +689,27 @@ export function useMessageStream({
|
|||
// Apply session-scoped fields when the event targets the active
|
||||
// session, OR when it's a global broadcast and we have no session.
|
||||
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
|
||||
const statePatch = sessionInfoStatePatch(payload)
|
||||
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
|
||||
const modelChanged = typeof payload?.model === 'string'
|
||||
const providerChanged = typeof payload?.provider === 'string'
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
if (apply) {
|
||||
const runtimeInfo: Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||
>
|
||||
> = {}
|
||||
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
runtimeInfo.model = payload!.model || ''
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setCurrentProvider(payload!.provider || '')
|
||||
runtimeInfo.provider = payload!.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
setCurrentCwd(payload.cwd)
|
||||
runtimeInfo.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
setCurrentBranch(payload.branch)
|
||||
runtimeInfo.branch = payload.branch
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
|
|
@ -666,28 +718,31 @@ export function useMessageStream({
|
|||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||
runtimeInfo.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
setCurrentServiceTier(payload.service_tier)
|
||||
runtimeInfo.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
setCurrentFastMode(payload.fast)
|
||||
runtimeInfo.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
runtimeInfo.yolo = payload.yolo
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && Object.keys(runtimeInfo).length > 0) {
|
||||
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
|
||||
}
|
||||
if (sessionId && hasStatePatch) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
...statePatch,
|
||||
branch: statePatch.branch ?? state.branch,
|
||||
cwd: statePatch.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
if (runningChanged && sessionId) {
|
||||
updateSessionState(sessionId, state => {
|
||||
const busy = Boolean(payload!.running)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
|
|
@ -42,6 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
|
|
@ -55,6 +56,7 @@ function Harness({
|
|||
onSeedState,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
storedSessionId
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
|
|
@ -62,6 +64,7 @@ function Harness({
|
|||
onSeedState?: (state: Record<string, unknown>) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
storedSessionId?: null | string
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
|
|
@ -69,6 +72,12 @@ function Harness({
|
|||
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
|
||||
}
|
||||
const localBusyRef = busyRef ?? { current: false }
|
||||
const stateRef = useRef({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never)
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
|
|
@ -79,17 +88,14 @@ function Harness({
|
|||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeStoredSession ?? (() => undefined),
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) => {
|
||||
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
||||
const next = updater({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never) as unknown as Record<string, unknown>
|
||||
const next = updater(stateRef.current) as unknown as Record<string, unknown>
|
||||
stateRef.current = next as never
|
||||
onSeedState?.(next)
|
||||
|
||||
return next as never
|
||||
|
|
@ -97,8 +103,12 @@ function Harness({
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
|
||||
}, [actions.steerPrompt, actions.submitText, onReady])
|
||||
onReady({
|
||||
cancelRun: actions.cancelRun,
|
||||
steerPrompt: actions.steerPrompt,
|
||||
submitText: actions.submitText
|
||||
})
|
||||
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -190,6 +200,68 @@ describe('usePromptActions /title', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions desktop slash pickers', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
|
||||
const resumeStoredSession = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
resumeStoredSession={resumeStoredSession}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/resume 20260610_130000_123abc')
|
||||
|
||||
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
|
||||
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'handoff.state') {
|
||||
return { state: 'pending' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const result = handle!.submitText('/handoff telegram')
|
||||
await vi.advanceTimersByTimeAsync(61_000)
|
||||
await result
|
||||
|
||||
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
|
||||
expect(calls).toContainEqual({
|
||||
method: 'handoff.fail',
|
||||
params: {
|
||||
error: expect.stringContaining('Timed out'),
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions submit / queue drain semantics', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
|
@ -562,6 +634,43 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||
})
|
||||
|
||||
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let interruptAttempts = 0
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'session.interrupt') {
|
||||
interruptAttempts += 1
|
||||
if (interruptAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
return {} as never
|
||||
}
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
|
||||
await handle!.cancelRun()
|
||||
|
||||
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
|
||||
const calls: string[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
|
|
@ -751,4 +860,3 @@ describe('uploadComposerAttachment remote read failures', () => {
|
|||
).rejects.toThrow('ENOENT: no such file')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,20 +4,24 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
|||
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||
import { stripAnsi } from '@/lib/ansi'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
optimisticAttachmentRef,
|
||||
parseCommandDispatch,
|
||||
parseSlashCommand,
|
||||
pathLabel,
|
||||
sessionTitle,
|
||||
SLASH_COMMAND_RE
|
||||
} from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
type DesktopActionId,
|
||||
type DesktopPickerId,
|
||||
desktopSlashUnavailableMessage,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isModelPickerCommand
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
|
|
@ -38,11 +42,13 @@ import {
|
|||
$busy,
|
||||
$connection,
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
setModelPickerOpen,
|
||||
setSessionPickerOpen,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
|
@ -50,12 +56,30 @@ import {
|
|||
import type {
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
HandoffStateResponse,
|
||||
ImageAttachResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
SlashExecResponse
|
||||
} from '../../types'
|
||||
|
||||
interface HandoffResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isSessionIdCandidate(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
|
||||
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
|
@ -84,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
|
|||
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
|
||||
}
|
||||
|
||||
function isSessionNotFoundError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
|
|
@ -245,6 +275,7 @@ interface PromptActionsOptions {
|
|||
handleSkinCommand: (arg: string) => string
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: () => void
|
||||
sttEnabled: boolean
|
||||
|
|
@ -260,6 +291,15 @@ interface SubmitTextOptions {
|
|||
fromQueue?: boolean
|
||||
}
|
||||
|
||||
/** Everything a slash handler needs about the invocation it's serving. */
|
||||
interface SlashActionCtx {
|
||||
arg: string
|
||||
command: string
|
||||
name: string
|
||||
recordInput: boolean
|
||||
sessionHint?: string
|
||||
}
|
||||
|
||||
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
|
||||
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
|
||||
|
||||
|
|
@ -310,6 +350,7 @@ export function usePromptActions({
|
|||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
|
|
@ -320,7 +361,11 @@ export function usePromptActions({
|
|||
|
||||
const appendSessionTextMessage = useCallback(
|
||||
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
||||
const body = text.trim()
|
||||
// Strip ANSI: slash-command output from the backend worker carries SGR
|
||||
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
|
||||
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
|
||||
// literal text.
|
||||
const body = stripAnsi(text).trim()
|
||||
|
||||
if (!body) {
|
||||
return
|
||||
|
|
@ -622,9 +667,7 @@ export function usePromptActions({
|
|||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
} catch (firstErr) {
|
||||
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
|
||||
|
||||
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
|
||||
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
|
|
@ -696,230 +739,124 @@ export function usePromptActions({
|
|||
]
|
||||
)
|
||||
|
||||
// Queue a handoff of this session to a messaging platform and watch it to
|
||||
// a terminal state. We only write the request through the gateway; the
|
||||
// separate `hermes gateway` process performs the actual transfer, so we
|
||||
// poll `handoff.state` (mirror of the CLI's block-poll) for the result.
|
||||
const handoffSession = useCallback(
|
||||
async (
|
||||
platform: string,
|
||||
options?: { onProgress?: (state: string) => void; sessionId?: string }
|
||||
): Promise<HandoffResult> => {
|
||||
const sid = options?.sessionId || activeSessionIdRef.current
|
||||
|
||||
if (!sid) {
|
||||
return { error: copy.sessionUnavailable, ok: false }
|
||||
}
|
||||
|
||||
const target = platform.trim().toLowerCase()
|
||||
|
||||
if (!target) {
|
||||
return { error: copy.handoff.failed(''), ok: false }
|
||||
}
|
||||
|
||||
try {
|
||||
options?.onProgress?.('pending')
|
||||
await requestGateway<HandoffRequestResponse>('handoff.request', {
|
||||
platform: target,
|
||||
session_id: sid
|
||||
})
|
||||
} catch (err) {
|
||||
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 60_000
|
||||
let lastState = 'pending'
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await delay(800)
|
||||
|
||||
let record: HandoffStateResponse
|
||||
|
||||
try {
|
||||
record = await requestGateway<HandoffStateResponse>('handoff.state', { session_id: sid })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const state = record.state || 'pending'
|
||||
|
||||
if (state !== lastState) {
|
||||
options?.onProgress?.(state)
|
||||
lastState = state
|
||||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
return { error: record.error || copy.handoff.failed(target), ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = await requestGateway<HandoffFailResponse>('handoff.fail', {
|
||||
error: copy.handoff.timedOut,
|
||||
session_id: sid
|
||||
}).catch(() => null)
|
||||
|
||||
if (cleanup?.state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
return { error: copy.handoff.timedOut, ok: false }
|
||||
},
|
||||
[activeSessionIdRef, appendSessionTextMessage, copy, requestGateway]
|
||||
)
|
||||
|
||||
const executeSlashCommand = useCallback(
|
||||
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
|
||||
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
|
||||
const command = commandText.trim()
|
||||
const { name, arg } = parseSlashCommand(command)
|
||||
const normalizedName = name.toLowerCase()
|
||||
const ensureSessionId = async (sessionHint?: string) =>
|
||||
sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!name) {
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (sessionId) {
|
||||
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'new' || normalizedName === 'reset') {
|
||||
startFreshSessionDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'branch' || normalizedName === 'fork') {
|
||||
await branchCurrentSession()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// /yolo maps to the status-bar YOLO control — a per-session approval
|
||||
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
|
||||
// it locally; the session-create path applies it on the first message.
|
||||
if (normalizedName === 'yolo') {
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
const next = !$yoloActive.get()
|
||||
|
||||
if (!sid) {
|
||||
setYoloActive(next)
|
||||
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const active = await setSessionYolo(requestGateway, sid, next)
|
||||
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
|
||||
} catch {
|
||||
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// /model opens the desktop model picker overlay — the same full
|
||||
// provider+model picker reachable from the status-bar model button —
|
||||
// instead of the headless prompt_toolkit modal the slash worker can't
|
||||
// render. With explicit args (`/model <name> [--provider ...]`) run the
|
||||
// switch directly through slash.exec so power users can still type it.
|
||||
if (isModelPickerCommand(`/${normalizedName}`)) {
|
||||
if (!arg.trim()) {
|
||||
setModelPickerOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!sid) {
|
||||
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SlashExecResponse>('slash.exec', {
|
||||
session_id: sid,
|
||||
command: command.replace(/^\/+/, '')
|
||||
})
|
||||
|
||||
const body = result?.output || `/${name}: model switched`
|
||||
appendSessionTextMessage(
|
||||
sid,
|
||||
'system',
|
||||
recordInput ? slashStatusText(command, body) : body
|
||||
)
|
||||
} catch (err) {
|
||||
appendSessionTextMessage(
|
||||
sid,
|
||||
'system',
|
||||
`error: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
|
||||
notify({ kind: 'success', message: handleSkinCommand(arg) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// /profile selects which profile new chats open in — no app relaunch.
|
||||
// A profile is per-session now, so an existing thread can't change its
|
||||
// profile mid-stream; `/profile <name>` instead points the next new chat
|
||||
// (and the current empty draft) at that profile's backend.
|
||||
if (normalizedName === 'profile') {
|
||||
const target = arg.trim()
|
||||
const current = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (!target) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
message: copy.profileStatus(current)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { profiles } = await getProfiles()
|
||||
const match = profiles.find(profile => profile.name === target)
|
||||
|
||||
if (!match) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: copy.unknownProfile,
|
||||
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeProfileKey(match.name)
|
||||
|
||||
$newChatProfile.set(key)
|
||||
// Swap the live gateway now so an empty draft sends into this
|
||||
// profile immediately; an existing thread keeps its own profile.
|
||||
await ensureGatewayProfile(key)
|
||||
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
|
||||
} catch (err) {
|
||||
notifyError(err, copy.setProfileFailed)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
// Resolve the target session plus a writer for inline slash output, or
|
||||
// notify + return null when none can be created. Folds the ensure / bail /
|
||||
// build-renderSlashOutput boilerplate every exec-style handler repeats.
|
||||
const withSlashOutput = async (
|
||||
ctx: SlashActionCtx
|
||||
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
|
||||
const sessionId = await ensureSessionId(ctx.sessionHint)
|
||||
|
||||
if (!sessionId) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: copy.sessionUnavailable,
|
||||
message: copy.createSessionFailed
|
||||
})
|
||||
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const render = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
|
||||
|
||||
return { render, sessionId }
|
||||
}
|
||||
|
||||
// `exec` commands (and unknown skill / quick commands the backend owns)
|
||||
// run on the gateway and render their text output inline. This is the only
|
||||
// path that talks to slash.exec / command.dispatch.
|
||||
async function runExec(ctx: SlashActionCtx): Promise<void> {
|
||||
const { arg, command, name } = ctx
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const renderSlashOutput = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
|
||||
|
||||
// /title <name> renames the session. Route through the gateway's
|
||||
// `session.title` RPC — the same path the TUI uses — NOT the REST
|
||||
// renameSession endpoint and NOT the slash worker.
|
||||
//
|
||||
// Why not the slash worker: it's a separate HermesCLI subprocess whose
|
||||
// SQLite write to the shared state.db can silently fail (notably on
|
||||
// Windows), and it never refreshes the sidebar.
|
||||
//
|
||||
// Why not REST renameSession: `sessionId` here is the *runtime* session
|
||||
// id returned by session.create — it is NOT the stored DB `sessions.id`,
|
||||
// and session.create deliberately does not persist a DB row until the
|
||||
// first turn. The REST PATCH endpoint resolves against the sessions
|
||||
// table, so a runtime id (or a brand-new, not-yet-persisted session)
|
||||
// 404s with "Session not found" on every platform. See #38508 / #38576.
|
||||
//
|
||||
// session.title maps the runtime id to the in-memory session, writes
|
||||
// through the gateway's own DB connection, and QUEUES the title
|
||||
// (`pending: true`) when the row isn't persisted yet — so it works for a
|
||||
// fresh chat too. refreshSessions() then pulls the authoritative title
|
||||
// back into the sidebar. A bare `/title` (no arg) still falls through to
|
||||
// the worker to display the current title.
|
||||
if (normalizedName === 'title' && arg) {
|
||||
try {
|
||||
const result = await requestGateway<SessionTitleResponse>('session.title', {
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
await refreshSessions().catch(() => undefined)
|
||||
renderSlashOutput(
|
||||
finalTitle
|
||||
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
|
||||
: 'Session title cleared.'
|
||||
)
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin') {
|
||||
renderSlashOutput(handleSkinCommand(arg))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'help' || name === 'commands') {
|
||||
try {
|
||||
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
|
||||
|
||||
renderSlashOutput(renderCommandsCatalog(catalog, copy))
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
if (!isDesktopSlashCommand(name)) {
|
||||
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
|
||||
|
|
@ -943,11 +880,7 @@ export function usePromptActions({
|
|||
|
||||
try {
|
||||
const dispatch = parseCommandDispatch(
|
||||
await requestGateway<unknown>('command.dispatch', {
|
||||
session_id: sessionId,
|
||||
name,
|
||||
arg
|
||||
})
|
||||
await requestGateway<unknown>('command.dispatch', { session_id: sessionId, name, arg })
|
||||
)
|
||||
|
||||
if (!dispatch) {
|
||||
|
|
@ -994,6 +927,261 @@ export function usePromptActions({
|
|||
}
|
||||
}
|
||||
|
||||
// One handler per `action` command. Adding a desktop-native command is a
|
||||
// registry row in desktop-slash-commands.ts plus an entry here — never a
|
||||
// new branch in a dispatch ladder.
|
||||
const actionHandlers: Record<DesktopActionId, (ctx: SlashActionCtx) => Promise<void>> = {
|
||||
new: async () => {
|
||||
startFreshSessionDraft()
|
||||
},
|
||||
branch: async () => {
|
||||
await branchCurrentSession()
|
||||
},
|
||||
// /yolo maps to the status-bar YOLO control — a per-session approval
|
||||
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
|
||||
// it locally; the session-create path applies it on the first message.
|
||||
yolo: async ({ sessionHint }) => {
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
const next = !$yoloActive.get()
|
||||
|
||||
if (!sid) {
|
||||
setYoloActive(next)
|
||||
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const active = await setSessionYolo(requestGateway, sid, next)
|
||||
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
|
||||
} catch {
|
||||
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
|
||||
}
|
||||
},
|
||||
// /handoff hands this session to a messaging platform. The platform is
|
||||
// completed inline in the slash popover (backend _handoff_completions),
|
||||
// so there is no overlay: `/handoff <platform>` runs the desktop's own
|
||||
// handoff RPC. cli_only on the backend, so it must not reach slash.exec.
|
||||
handoff: async ({ arg, command, recordInput, sessionHint }) => {
|
||||
const platform = arg.trim()
|
||||
|
||||
if (!platform) {
|
||||
notify({ kind: 'success', message: copy.handoff.pickPlatform })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
|
||||
if (!sid) {
|
||||
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await handoffSession(platform, { sessionId: sid })
|
||||
|
||||
if (!result.ok && result.error) {
|
||||
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error)
|
||||
}
|
||||
},
|
||||
// /profile selects which profile new chats open in — no app relaunch.
|
||||
// A profile is per-session now, so an existing thread can't change its
|
||||
// profile mid-stream; `/profile <name>` points the next new chat (and
|
||||
// the current empty draft) at that profile's backend.
|
||||
profile: async ({ arg }) => {
|
||||
const target = arg.trim()
|
||||
const current = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (!target) {
|
||||
notify({ kind: 'success', message: copy.profileStatus(current) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { profiles } = await getProfiles()
|
||||
const match = profiles.find(profile => profile.name === target)
|
||||
|
||||
if (!match) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: copy.unknownProfile,
|
||||
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeProfileKey(match.name)
|
||||
|
||||
$newChatProfile.set(key)
|
||||
await ensureGatewayProfile(key)
|
||||
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
|
||||
} catch (err) {
|
||||
notifyError(err, copy.setProfileFailed)
|
||||
}
|
||||
},
|
||||
skin: async ({ arg, command, recordInput, sessionHint }) => {
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
const message = handleSkinCommand(arg)
|
||||
|
||||
// No session to print into yet — surface it as a toast instead of
|
||||
// spinning up a backend session just to change the theme.
|
||||
if (!sid) {
|
||||
notify({ kind: 'success', message })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message)
|
||||
},
|
||||
// /title <name> renames via the gateway's session.title RPC — the same
|
||||
// path the TUI uses, NOT REST renameSession (which 404s on runtime ids)
|
||||
// nor the slash worker (whose DB write can silently fail). Bare /title
|
||||
// shows the current title, which the worker owns, so delegate to exec.
|
||||
title: async ctx => {
|
||||
if (!ctx.arg) {
|
||||
await runExec(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
const { arg } = ctx
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionTitleResponse>('session.title', {
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
await refreshSessions().catch(() => undefined)
|
||||
renderSlashOutput(
|
||||
finalTitle
|
||||
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
|
||||
: 'Session title cleared.'
|
||||
)
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
help: async ctx => {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
try {
|
||||
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
|
||||
|
||||
renderSlashOutput(renderCommandsCatalog(catalog, copy))
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picker commands open a desktop overlay; a typed arg is resolved by that
|
||||
// picker so the command never dead-ends or falls through to the backend.
|
||||
const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise<void> => {
|
||||
if (pickerId === 'model') {
|
||||
if (!ctx.arg.trim()) {
|
||||
setModelPickerOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Power users can still type `/model <name>` — run it on the backend.
|
||||
await runExec(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// session picker — /resume, /sessions, /switch
|
||||
const query = ctx.arg.trim()
|
||||
|
||||
if (!query) {
|
||||
setSessionPickerOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = $sessions.get()
|
||||
const lower = query.toLowerCase()
|
||||
|
||||
const match =
|
||||
sessions.find(session => session.id === query) ||
|
||||
sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) ||
|
||||
sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower))
|
||||
|
||||
if (!match) {
|
||||
if (isSessionIdCandidate(query)) {
|
||||
await resumeStoredSession(query)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind: 'error', message: copy.resumeFailed })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await resumeStoredSession(match.id)
|
||||
}
|
||||
|
||||
// The whole dispatcher: resolve the command's desktop surface, then act on
|
||||
// its kind. No per-command ladder — behavior lives in the registry.
|
||||
async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise<void> {
|
||||
const command = commandText.trim()
|
||||
const { name, arg } = parseSlashCommand(command)
|
||||
|
||||
if (!name) {
|
||||
const sessionId = await ensureSessionId(sessionHint)
|
||||
|
||||
if (sessionId) {
|
||||
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint }
|
||||
const surface = resolveDesktopCommand(`/${name}`)?.surface
|
||||
|
||||
switch (surface?.kind) {
|
||||
case 'unavailable': {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'picker':
|
||||
return openPicker(surface.picker, ctx)
|
||||
|
||||
case 'action':
|
||||
return actionHandlers[surface.action](ctx)
|
||||
|
||||
default:
|
||||
// exec spec, or an unknown skill / quick command the backend owns.
|
||||
return runExec(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
|
||||
},
|
||||
[
|
||||
|
|
@ -1004,8 +1192,10 @@ export function usePromptActions({
|
|||
copy,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
handoffSession,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
]
|
||||
|
|
@ -1087,11 +1277,39 @@ export function usePromptActions({
|
|||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch (err) {
|
||||
let stopError = err
|
||||
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
await requestGateway('session.interrupt', { session_id: recoveredId })
|
||||
|
||||
return
|
||||
}
|
||||
} catch (resumeErr) {
|
||||
stopError = resumeErr
|
||||
}
|
||||
}
|
||||
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
notifyError(err, copy.stopFailed)
|
||||
notifyError(stopError, copy.stopFailed)
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState])
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
copy.stopFailed,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
|
||||
// Steer = nudge the live turn without interrupting: the gateway appends the
|
||||
// text to the next tool result so the model reads it on its next iteration
|
||||
|
|
@ -1314,6 +1532,7 @@ export function usePromptActions({
|
|||
cancelRun,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
|
|||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -19,7 +18,6 @@ import {
|
|||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
workspaceCwdForNewSession,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
|
|
@ -41,10 +39,11 @@ import {
|
|||
setSessionStartedAt,
|
||||
setSessionsTotal,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
setYoloActive,
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../types'
|
||||
|
|
@ -210,16 +209,27 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
|||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> | null {
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionState: Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> = {}
|
||||
const sessionState: SessionRuntimeStatePatch = {}
|
||||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
|
|
@ -227,12 +237,12 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
|||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
||||
if (info.model) {
|
||||
if (typeof info.model === 'string') {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
}
|
||||
|
||||
if (info.provider) {
|
||||
if (typeof info.provider === 'string') {
|
||||
setCurrentProvider(info.provider)
|
||||
sessionState.provider = info.provider
|
||||
}
|
||||
|
|
@ -248,7 +258,9 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
|||
}
|
||||
|
||||
if (typeof info.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(info.personality))
|
||||
const personality = normalizePersonalityValue(info.personality)
|
||||
setCurrentPersonality(personality)
|
||||
sessionState.personality = personality
|
||||
}
|
||||
|
||||
if (typeof info.reasoning_effort === 'string') {
|
||||
|
|
@ -278,6 +290,16 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
|||
return sessionState
|
||||
}
|
||||
|
||||
function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
|
||||
setCurrentModel(stored?.model || '')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentPersonality('')
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
|
|
@ -329,8 +351,7 @@ export function useSessionActions({
|
|||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||
setFreshDraftReady(true)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
|
|
@ -352,11 +373,13 @@ export function useSessionActions({
|
|||
// Pass the owning profile so a new chat under a non-launch profile (global
|
||||
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||
const newChatProfile = $newChatProfile.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
||||
})
|
||||
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
|
|
@ -465,18 +488,29 @@ export function useSessionActions({
|
|||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const cachedViewState =
|
||||
!cachedState.model && stored?.model != null
|
||||
? {
|
||||
...cachedState,
|
||||
model: stored.model || ''
|
||||
}
|
||||
: cachedState
|
||||
|
||||
if (cachedViewState !== cachedState) {
|
||||
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setActiveSessionId(cachedRuntimeId)
|
||||
activeSessionIdRef.current = cachedRuntimeId
|
||||
syncSessionStateToView(cachedRuntimeId, cachedState)
|
||||
setCurrentCwd(cachedState.cwd)
|
||||
setCurrentBranch(cachedState.branch)
|
||||
syncSessionStateToView(cachedRuntimeId, cachedViewState)
|
||||
setCurrentCwd(cachedViewState.cwd)
|
||||
setCurrentBranch(cachedViewState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
try {
|
||||
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
|
|
@ -516,6 +550,7 @@ export function useSessionActions({
|
|||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setSessionStartedAt(Date.now())
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
|
|
@ -606,8 +641,6 @@ export function useSessionActions({
|
|||
}),
|
||||
storedSessionId
|
||||
)
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
|
|
@ -730,8 +763,6 @@ export function useSessionActions({
|
|||
selectedStoredSessionIdRef.current = routedSessionId
|
||||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
|
|
@ -872,6 +903,12 @@ export function useSessionActions({
|
|||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||
// A sidebar refresh can race the optimistic removal while the PATCH is
|
||||
// in flight and briefly reinsert the still-unarchived backend row. Win
|
||||
// that race after the mutation succeeds so right-click → Archive does
|
||||
// not appear to do nothing until the next full refresh.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { act, cleanup, render } from '@testing-library/react'
|
|||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentServiceTier,
|
||||
$turnStartedAt,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
|
|
@ -46,12 +59,22 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
|||
return null as unknown as number
|
||||
})
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
|
||||
|
|
@ -115,4 +138,78 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
|||
})
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('mirrors the focused session model metadata when switching from a cached session', () => {
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'bg-runtime',
|
||||
state => ({
|
||||
...state,
|
||||
fast: true,
|
||||
model: 'anthropic/claude-opus-4.8',
|
||||
provider: 'anthropic',
|
||||
reasoningEffort: 'high',
|
||||
serviceTier: 'priority'
|
||||
}),
|
||||
'bg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
// Background metadata is cached but must not bleed into the visible statusbar.
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
|
||||
expect($currentProvider.get()).toBe('anthropic')
|
||||
expect($currentReasoningEffort.get()).toBe('high')
|
||||
expect($currentServiceTier.get()).toBe('priority')
|
||||
expect($currentFastMode.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears stale model metadata when the newly focused session has no cached value', () => {
|
||||
setCurrentModel('previous-model')
|
||||
setCurrentProvider('previous-provider')
|
||||
setCurrentReasoningEffort('high')
|
||||
setCurrentServiceTier('priority')
|
||||
setCurrentFastMode(true)
|
||||
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
|
||||
})
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentProvider.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentServiceTier.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
noteSessionActivity,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
|
|
@ -53,6 +54,16 @@ interface SessionStateCacheOptions {
|
|||
setMessages: (messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
function syncRuntimeMetadataToView(state: ClientSessionState) {
|
||||
setCurrentModel(state.model ?? '')
|
||||
setCurrentProvider(state.provider ?? '')
|
||||
setCurrentReasoningEffort(state.reasoningEffort ?? '')
|
||||
setCurrentServiceTier(state.serviceTier ?? '')
|
||||
setCurrentFastMode(state.fast ?? false)
|
||||
setYoloActive(state.yolo ?? false)
|
||||
setCurrentPersonality(state.personality ?? '')
|
||||
}
|
||||
|
||||
export function useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
|
|
@ -137,12 +148,7 @@ export function useSessionStateCache({
|
|||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
setCurrentModel(pending.state.model)
|
||||
setCurrentProvider(pending.state.provider)
|
||||
setCurrentReasoningEffort(pending.state.reasoningEffort)
|
||||
setCurrentServiceTier(pending.state.serviceTier)
|
||||
setCurrentFastMode(pending.state.fast)
|
||||
setYoloActive(pending.state.yolo)
|
||||
syncRuntimeMetadataToView(pending.state)
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
|
|
@ -167,6 +173,7 @@ export function useSessionStateCache({
|
|||
return
|
||||
}
|
||||
|
||||
syncRuntimeMetadataToView(state)
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
// Terminal / attention transitions (turn finished, error, or the agent is
|
||||
|
|
|
|||
|
|
@ -61,6 +61,26 @@ export interface SessionTitleResponse {
|
|||
session_key?: string
|
||||
}
|
||||
|
||||
export interface HandoffRequestResponse {
|
||||
queued?: boolean
|
||||
session_key?: string
|
||||
platform?: string
|
||||
// Human-readable home channel name for the destination platform.
|
||||
home_name?: string
|
||||
}
|
||||
|
||||
export interface HandoffStateResponse {
|
||||
// '' | 'pending' | 'running' | 'completed' | 'failed'
|
||||
state?: string
|
||||
platform?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface HandoffFailResponse {
|
||||
failed?: boolean
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface ExecCommandDispatchResponse {
|
||||
type: 'exec' | 'plugin'
|
||||
output?: string
|
||||
|
|
@ -109,6 +129,7 @@ export interface ClientSessionState {
|
|||
serviceTier: string
|
||||
fast: boolean
|
||||
yolo: boolean
|
||||
personality: string
|
||||
busy: boolean
|
||||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) {
|
|||
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
|
||||
}
|
||||
|
||||
export function directiveIconElement(type: string) {
|
||||
function iconElementFromPaths(paths: string[]) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
|
||||
svg.setAttribute('fill', 'none')
|
||||
|
|
@ -74,7 +74,7 @@ export function directiveIconElement(type: string) {
|
|||
svg.setAttribute('viewBox', '0 0 24 24')
|
||||
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
|
||||
for (const d of iconPathsFor(type)) {
|
||||
for (const d of paths) {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
path.setAttribute('d', d)
|
||||
svg.append(path)
|
||||
|
|
@ -83,6 +83,46 @@ export function directiveIconElement(type: string) {
|
|||
return svg
|
||||
}
|
||||
|
||||
export function directiveIconElement(type: string) {
|
||||
return iconElementFromPaths(iconPathsFor(type))
|
||||
}
|
||||
|
||||
/** Per-type slash-command pill styling. The composer inserts these chips when a
|
||||
* command is picked; the kind drives a theme-aware accent so commands, skills,
|
||||
* and themes read distinctly (Cursor-style). */
|
||||
export type SlashChipKind = 'command' | 'skill' | 'theme'
|
||||
|
||||
const SLASH_ICON_PATHS: Record<SlashChipKind, string[]> = {
|
||||
command: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||||
skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'],
|
||||
theme: [
|
||||
'M3 21v-4a4 4 0 1 1 4 4h-4',
|
||||
'M21 3a16 16 0 0 0 -12.8 10.2',
|
||||
'M21 3a16 16 0 0 1 -10.2 12.8',
|
||||
'M10.6 9a9 9 0 0 1 4.4 4.4'
|
||||
]
|
||||
}
|
||||
|
||||
const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
|
||||
command:
|
||||
'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]',
|
||||
skill:
|
||||
'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]',
|
||||
theme:
|
||||
'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
|
||||
}
|
||||
|
||||
export const SLASH_CHIP_BASE_CLASS =
|
||||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
|
||||
|
||||
export function slashChipClass(kind: SlashChipKind): string {
|
||||
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
|
||||
}
|
||||
|
||||
export function slashIconElement(kind: SlashChipKind) {
|
||||
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
|
||||
}
|
||||
|
||||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
<svg
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
|
|
|
|||
|
|
@ -929,22 +929,42 @@ const SystemMessage: FC = () => {
|
|||
const slashStatus = text.match(SLASH_STATUS_RE)
|
||||
|
||||
if (slashStatus?.groups) {
|
||||
const output = slashStatus.groups.output.trim()
|
||||
// Single-line status (e.g. "model → x") reads best centered inline; padded
|
||||
// multiline output (catalogs, usage tables) needs left-aligned, wider room
|
||||
// or the column alignment breaks.
|
||||
const multiline = output.includes('\n')
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
|
||||
className={cn(
|
||||
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
|
||||
multiline ? 'text-left' : 'text-center'
|
||||
)}
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={slashStatus.groups.output.trim()} />
|
||||
{multiline ? (
|
||||
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
|
||||
) : (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
|
||||
</>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const multiline = text.includes('\n')
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
|
||||
className={cn(
|
||||
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55',
|
||||
multiline ? 'text-left' : 'text-center'
|
||||
)}
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
|
|
|
|||
108
apps/desktop/src/components/session-picker.tsx
Normal file
108
apps/desktop/src/components/session-picker.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { listSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Check, MessageCircle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SessionPickerDialogProps {
|
||||
/** Stored id of the session currently open, so it can be flagged in the list. */
|
||||
activeStoredSessionId?: string | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onResume: (storedSessionId: string) => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop equivalent of the TUI's sessions overlay (`/resume`, `/sessions`,
|
||||
* `/switch`): a focused, type-to-filter list of recent sessions that resumes
|
||||
* the picked one. Mirrors the command palette's cmdk surface but scoped to
|
||||
* sessions only, so `/resume` feels first-class instead of falling through to
|
||||
* the headless slash worker (which can't render the picker).
|
||||
*/
|
||||
export function SessionPickerDialog({
|
||||
activeStoredSessionId,
|
||||
onOpenChange,
|
||||
onResume,
|
||||
open
|
||||
}: SessionPickerDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
enabled: open,
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
queryKey: ['session-picker', 'sessions']
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data])
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.sections.sessions}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop>
|
||||
<CommandInput
|
||||
onValueChange={setSearch}
|
||||
placeholder={t.commandCenter.searchPlaceholder}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
<CommandGroup
|
||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
||||
heading={t.commandCenter.sections.sessions}
|
||||
>
|
||||
{sessions.map(session => {
|
||||
const title = sessionTitle(session)
|
||||
const preview = session.preview?.trim()
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="gap-2.5"
|
||||
key={session.id}
|
||||
onSelect={() => {
|
||||
onResume(session.id)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
value={`${title} ${preview ?? ''} ${session.id}`}
|
||||
>
|
||||
<MessageCircle className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col leading-snug">
|
||||
<span className="truncate">{title}</span>
|
||||
{preview ? (
|
||||
<span className="truncate text-xs text-muted-foreground/70">{preview}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0 text-foreground',
|
||||
session.id !== activeStoredSessionId && 'invisible'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -1778,7 +1778,14 @@ export const en: Translations = {
|
|||
clipboard: 'Clipboard',
|
||||
noClipboardImage: 'No image found in clipboard',
|
||||
clipboardPasteFailed: 'Clipboard paste failed',
|
||||
dropFiles: 'Drop files'
|
||||
dropFiles: 'Drop files',
|
||||
handoff: {
|
||||
pickPlatform: 'Choose a destination',
|
||||
success: platform => `Handed off to ${platform}. Resume here anytime.`,
|
||||
systemNote: platform => `↻ Handed off to ${platform} — resume here anytime.`,
|
||||
failed: error => `Handoff failed: ${error}`,
|
||||
timedOut: 'Timed out waiting for the gateway. Is `hermes gateway` running?'
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -1914,7 +1914,14 @@ export const ja = defineLocale({
|
|||
clipboard: 'クリップボード',
|
||||
noClipboardImage: 'クリップボードに画像が見つかりません',
|
||||
clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました',
|
||||
dropFiles: 'ファイルをドロップ'
|
||||
dropFiles: 'ファイルをドロップ',
|
||||
handoff: {
|
||||
pickPlatform: '送信先を選択',
|
||||
success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`,
|
||||
systemNote: platform => `↻ ${platform} に引き継ぎました — いつでもここで再開できます。`,
|
||||
failed: error => `引き継ぎに失敗しました: ${error}`,
|
||||
timedOut: 'ゲートウェイの待機がタイムアウトしました。`hermes gateway` は起動していますか?'
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -1437,6 +1437,13 @@ export interface Translations {
|
|||
noClipboardImage: string
|
||||
clipboardPasteFailed: string
|
||||
dropFiles: string
|
||||
handoff: {
|
||||
pickPlatform: string
|
||||
success: (platform: string) => string
|
||||
systemNote: (platform: string) => string
|
||||
failed: (error: string) => string
|
||||
timedOut: string
|
||||
}
|
||||
}
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -1873,7 +1873,14 @@ export const zhHant = defineLocale({
|
|||
clipboard: '剪貼簿',
|
||||
noClipboardImage: '剪貼簿中沒有圖片',
|
||||
clipboardPasteFailed: '剪貼簿貼上失敗',
|
||||
dropFiles: '拖曳檔案'
|
||||
dropFiles: '拖曳檔案',
|
||||
handoff: {
|
||||
pickPlatform: '選擇目標平台',
|
||||
success: platform => `已移交到 ${platform}。隨時可在此處恢復。`,
|
||||
systemNote: platform => `↻ 已移交到 ${platform} — 隨時可在此處恢復。`,
|
||||
failed: error => `移交失敗:${error}`,
|
||||
timedOut: '等待閘道逾時。`hermes gateway` 是否正在執行?'
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -1956,7 +1956,14 @@ export const zh: Translations = {
|
|||
clipboard: '剪贴板',
|
||||
noClipboardImage: '剪贴板中没有图片',
|
||||
clipboardPasteFailed: '粘贴剪贴板失败',
|
||||
dropFiles: '拖放文件'
|
||||
dropFiles: '拖放文件',
|
||||
handoff: {
|
||||
pickPlatform: '选择目标平台',
|
||||
success: platform => `已移交到 ${platform}。随时可在此处恢复。`,
|
||||
systemNote: platform => `↻ 已移交到 ${platform} — 随时可在此处恢复。`,
|
||||
failed: error => `移交失败:${error}`,
|
||||
timedOut: '等待网关超时。`hermes gateway` 是否正在运行?'
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -173,3 +173,14 @@ export function hasAnsiCodes(input: string): boolean {
|
|||
// eslint-disable-next-line no-control-regex
|
||||
return /\x1b\[/.test(input)
|
||||
}
|
||||
|
||||
/** Remove all ANSI escape sequences, returning plain text. Use when output is
|
||||
* rendered as text (e.g. chat system messages) rather than styled segments —
|
||||
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
|
||||
export function stripAnsi(input: string): string {
|
||||
if (!input) {
|
||||
return input
|
||||
}
|
||||
|
||||
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function createClientSessionState(
|
|||
serviceTier: '',
|
||||
fast: false,
|
||||
yolo: false,
|
||||
personality: '',
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import {
|
|||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isDesktopSlashSuggestion,
|
||||
isModelPickerCommand
|
||||
isModelPickerCommand,
|
||||
isPickerCommand,
|
||||
resolveDesktopCommand
|
||||
} from './desktop-slash-commands'
|
||||
|
||||
describe('desktop slash command curation', () => {
|
||||
|
|
@ -38,6 +40,18 @@ describe('desktop slash command curation', () => {
|
|||
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces /tools, /save, and /personality on the desktop', () => {
|
||||
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/save')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/tools')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/save')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/personality')).toBe(true)
|
||||
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
|
||||
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
|
||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows aliases to execute without cluttering the popover', () => {
|
||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||
|
|
@ -74,6 +88,24 @@ describe('desktop slash command curation', () => {
|
|||
['/new', 'Start a new desktop chat'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
])
|
||||
// skill_count is recomputed from the filtered output (only /ship-it is an
|
||||
// extension command — /new is a built-in) so the /help footer matches what
|
||||
// the user actually sees rather than echoing the unfiltered backend total.
|
||||
expect(filtered.skill_count).toBe(1)
|
||||
})
|
||||
|
||||
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
|
||||
const filtered = filterDesktopCommandsCatalog({
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/clear', 'Clear terminal screen'],
|
||||
['/gif-search', 'Search for a gif'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
],
|
||||
skill_count: 12
|
||||
})
|
||||
|
||||
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
|
||||
expect(filtered.skill_count).toBe(2)
|
||||
})
|
||||
|
||||
|
|
@ -123,4 +155,26 @@ describe('desktop slash command curation', () => {
|
|||
expect(isModelPickerCommand('/new')).toBe(false)
|
||||
expect(isModelPickerCommand('/skills')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives /resume (and its aliases) a first-class session picker surface', () => {
|
||||
expect(isPickerCommand('/resume', 'session')).toBe(true)
|
||||
expect(isPickerCommand('/sessions', 'session')).toBe(true)
|
||||
expect(isPickerCommand('/switch', 'session')).toBe(true)
|
||||
// Unlike /model, /resume shows in the popover; its aliases stay hidden.
|
||||
expect(isDesktopSlashSuggestion('/resume')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/sessions')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/switch')).toBe(true)
|
||||
// The session picker is distinct from the model picker.
|
||||
expect(isModelPickerCommand('/resume')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves commands and aliases to their declared surface', () => {
|
||||
expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
|
||||
expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
|
||||
expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' })
|
||||
expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' })
|
||||
expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' })
|
||||
// Skill / quick commands aren't in the registry.
|
||||
expect(resolveDesktopCommand('/gif-search')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,110 +22,161 @@ export interface DesktopThemeCommandOption {
|
|||
name: string
|
||||
}
|
||||
|
||||
const DESKTOP_COMMAND_META = [
|
||||
['/agents', 'Show active desktop sessions and running tasks'],
|
||||
['/background', 'Run a prompt in the background'],
|
||||
['/branch', 'Branch the latest message into a new chat'],
|
||||
['/compress', 'Compress this conversation context'],
|
||||
['/debug', 'Create a debug report'],
|
||||
['/goal', 'Manage the standing goal for this session'],
|
||||
['/help', 'Show desktop slash commands'],
|
||||
['/new', 'Start a new desktop chat'],
|
||||
['/profile', 'Switch the active Hermes profile'],
|
||||
['/queue', 'Queue a prompt for the next turn'],
|
||||
['/resume', 'Resume a saved session'],
|
||||
['/retry', 'Retry the last user message'],
|
||||
['/rollback', 'List or restore filesystem checkpoints'],
|
||||
['/skin', 'Switch desktop theme or cycle to the next one'],
|
||||
['/status', 'Show current session status'],
|
||||
['/steer', 'Steer the current run after the next tool call'],
|
||||
['/stop', 'Stop running background processes'],
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session'],
|
||||
['/version', 'Show Hermes Agent version'],
|
||||
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
|
||||
] as const
|
||||
/**
|
||||
* Local client action a command resolves to. Each id maps to exactly one
|
||||
* handler in the dispatcher (`use-prompt-actions`), so adding a command never
|
||||
* means adding a branch to a switch ladder — you add a row here + a handler
|
||||
* keyed by the id.
|
||||
*/
|
||||
export type DesktopActionId =
|
||||
| 'branch'
|
||||
| 'handoff'
|
||||
| 'help'
|
||||
| 'new'
|
||||
| 'profile'
|
||||
| 'skin'
|
||||
| 'title'
|
||||
| 'yolo'
|
||||
|
||||
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
|
||||
/** A command fulfilled by opening a desktop overlay picker. */
|
||||
export type DesktopPickerId = 'model' | 'session'
|
||||
|
||||
const DESKTOP_ALIASES = new Map([
|
||||
['/bg', '/background'],
|
||||
['/btw', '/background'],
|
||||
['/fork', '/branch'],
|
||||
['/q', '/queue'],
|
||||
['/reload_mcp', '/reload-mcp'],
|
||||
['/reload_skills', '/reload-skills'],
|
||||
['/reset', '/new'],
|
||||
['/tasks', '/agents']
|
||||
])
|
||||
/** Why a known Hermes command has no desktop UI surface. */
|
||||
export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal'
|
||||
|
||||
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
|
||||
/**
|
||||
* How the desktop fulfils a command. This is the single discriminator the
|
||||
* dispatcher, popover, pills, and pickers all read — no parallel block-lists.
|
||||
*
|
||||
* - `action` → handled by a local client handler (new chat, branch, …)
|
||||
* - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is
|
||||
* resolved by that picker instead of falling through
|
||||
* - `exec` → runs on the backend via slash.exec / command.dispatch and
|
||||
* renders its text output inline
|
||||
* - `unavailable`→ a known command with genuinely no desktop UI (terminal-only,
|
||||
* messaging-only, …); shows a reason instead of executing
|
||||
*/
|
||||
export type DesktopCommandSurface =
|
||||
| { kind: 'action'; action: DesktopActionId }
|
||||
| { kind: 'picker'; picker: DesktopPickerId }
|
||||
| { kind: 'exec' }
|
||||
| { kind: 'unavailable'; reason: DesktopUnavailableReason }
|
||||
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model'])
|
||||
export interface DesktopCommandSpec {
|
||||
/** Canonical command, leading slash included (e.g. `/resume`). */
|
||||
name: string
|
||||
/** Popover/help label; omitted for unavailable commands (never surfaced). */
|
||||
description?: string
|
||||
aliases?: string[]
|
||||
surface: DesktopCommandSurface
|
||||
/**
|
||||
* Hide from the slash popover / completions while still letting it execute.
|
||||
* Used for picker commands reachable from chrome (the model picker lives on
|
||||
* the status bar), so the popover doesn't dead-end on inline completion.
|
||||
*/
|
||||
hidden?: boolean
|
||||
/**
|
||||
* The command has an inline options "screen" (theme / personality / session /
|
||||
* platform / toolset list). Picking the bare command in the popover expands to
|
||||
* that argument step instead of committing — mirroring typing `/<cmd> ` by hand.
|
||||
*/
|
||||
args?: boolean
|
||||
}
|
||||
|
||||
const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/browser',
|
||||
'/busy',
|
||||
'/clear',
|
||||
'/commands',
|
||||
'/compact',
|
||||
'/config',
|
||||
'/copy',
|
||||
'/cron',
|
||||
'/details',
|
||||
'/exit',
|
||||
'/footer',
|
||||
'/gateway',
|
||||
'/gquota',
|
||||
'/history',
|
||||
'/image',
|
||||
'/indicator',
|
||||
'/logs',
|
||||
'/mouse',
|
||||
'/paste',
|
||||
'/platforms',
|
||||
'/plugins',
|
||||
'/quit',
|
||||
'/redraw',
|
||||
'/reload',
|
||||
'/restart',
|
||||
'/save',
|
||||
'/sb',
|
||||
'/set-home',
|
||||
'/sethome',
|
||||
'/snap',
|
||||
'/snapshot',
|
||||
'/statusbar',
|
||||
'/toolsets',
|
||||
'/tools',
|
||||
'/update',
|
||||
'/verbose'
|
||||
])
|
||||
const exec = (): DesktopCommandSurface => ({ kind: 'exec' })
|
||||
const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id })
|
||||
const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
|
||||
const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
|
||||
|
||||
const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
|
||||
/**
|
||||
* THE source of truth for desktop slash commands. Everything below — execution
|
||||
* gating, popover suggestions, catalog filtering, pill grouping, and the
|
||||
* dispatcher's behavior — derives from this one table.
|
||||
*/
|
||||
const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
// Local client actions
|
||||
{ name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') },
|
||||
{ name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') },
|
||||
{ name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
|
||||
{ name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true },
|
||||
{ name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') },
|
||||
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
|
||||
{ name: '/title', description: 'Rename the current session', surface: action('title') },
|
||||
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
|
||||
|
||||
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
|
||||
// Overlay pickers
|
||||
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
|
||||
{
|
||||
name: '/resume',
|
||||
description: 'Resume a saved session',
|
||||
aliases: ['/sessions', '/switch'],
|
||||
surface: picker('session'),
|
||||
args: true
|
||||
},
|
||||
|
||||
const ADVANCED_COMMANDS = new Set([
|
||||
'/curator',
|
||||
'/fast',
|
||||
'/insights',
|
||||
'/kanban',
|
||||
'/personality',
|
||||
'/reasoning',
|
||||
'/reload-mcp',
|
||||
'/reload-skills',
|
||||
'/voice'
|
||||
])
|
||||
// Backend-executed commands that render useful inline output
|
||||
{ name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() },
|
||||
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
|
||||
{ name: '/compress', description: 'Compress this conversation context', surface: exec() },
|
||||
{ name: '/debug', description: 'Create a debug report', surface: exec() },
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
|
||||
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
|
||||
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
|
||||
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
|
||||
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
|
||||
{ name: '/save', description: 'Save the current transcript to JSON', surface: exec() },
|
||||
{ name: '/status', description: 'Show current session status', surface: exec() },
|
||||
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() },
|
||||
{ name: '/stop', description: 'Stop running background processes', surface: exec() },
|
||||
{ name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
|
||||
{ name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() },
|
||||
{ name: '/usage', description: 'Show token usage for this session', surface: exec() },
|
||||
{ name: '/version', description: 'Show Hermes Agent version', surface: exec() },
|
||||
|
||||
const BLOCKED_COMMANDS = new Set([
|
||||
...PICKER_OWNED_COMMANDS,
|
||||
...TERMINAL_ONLY_COMMANDS,
|
||||
...MESSAGING_ONLY_COMMANDS,
|
||||
...SETTINGS_OWNED_COMMANDS,
|
||||
...ADVANCED_COMMANDS
|
||||
])
|
||||
// No desktop surface, but carry an alias (underscore spelling variants).
|
||||
{ name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') },
|
||||
{ name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') }
|
||||
]
|
||||
|
||||
// Known commands with no desktop surface (and no alias) — a flat name list
|
||||
// per reason beats 40 identical object literals.
|
||||
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
|
||||
terminal: [
|
||||
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
|
||||
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
|
||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||
],
|
||||
messaging: ['/approve', '/deny'],
|
||||
settings: ['/skills'],
|
||||
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
|
||||
}
|
||||
|
||||
const ALL_SPECS: readonly DesktopCommandSpec[] = [
|
||||
...DESKTOP_COMMAND_SPECS,
|
||||
...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap(
|
||||
([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) }))
|
||||
)
|
||||
]
|
||||
|
||||
const SPEC_BY_NAME = new Map<string, DesktopCommandSpec>(ALL_SPECS.map(spec => [spec.name, spec]))
|
||||
|
||||
const ALIAS_TO_CANONICAL = new Map<string, string>(
|
||||
ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const))
|
||||
)
|
||||
|
||||
const UNAVAILABLE_MESSAGE: Record<DesktopUnavailableReason, (command: string) => string> = {
|
||||
advanced: command =>
|
||||
`${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`,
|
||||
messaging: command => `${command} is only used from messaging platforms.`,
|
||||
settings: command => `${command} is managed from the desktop sidebar.`,
|
||||
terminal: command => `${command} is only available in the terminal interface.`
|
||||
}
|
||||
|
||||
const PICKER_UNAVAILABLE_MESSAGE: Record<DesktopPickerId, (command: string) => string> = {
|
||||
model: command => `${command} uses the desktop model picker instead of a slash command.`,
|
||||
session: command => `${command} uses the desktop session picker instead of a slash command.`
|
||||
}
|
||||
|
||||
function normalizeCommand(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
|
|
@ -137,27 +188,25 @@ function normalizeCommand(command: string): string {
|
|||
export function canonicalDesktopSlashCommand(command: string): string {
|
||||
const normalized = normalizeCommand(command)
|
||||
|
||||
return DESKTOP_ALIASES.get(normalized) || normalized
|
||||
return ALIAS_TO_CANONICAL.get(normalized) || normalized
|
||||
}
|
||||
|
||||
export function isDesktopSlashCommand(command: string): boolean {
|
||||
/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */
|
||||
export function resolveDesktopCommand(command: string): DesktopCommandSpec | null {
|
||||
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null
|
||||
}
|
||||
|
||||
function isKnownHermesSlashCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
|
||||
return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* An "extension" command is anything the backend surfaces that is NOT one of
|
||||
* Hermes' built-in slash commands — i.e. skill commands (`/gif-search`,
|
||||
* `/codex`, …) and user-defined quick commands. These are user-activated, so
|
||||
* they should appear in the desktop slash palette even though they aren't in
|
||||
* the curated `DESKTOP_COMMANDS` allow-list. This mirrors the predicate in
|
||||
* `isDesktopSlashCommand` that already lets them EXECUTE when typed.
|
||||
* they appear in the desktop slash palette and execute when typed.
|
||||
*/
|
||||
export function isDesktopSlashExtensionCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
|
|
@ -169,63 +218,85 @@ export function isDesktopSlashExtensionCommand(command: string): boolean {
|
|||
return !isKnownHermesSlashCommand(normalized)
|
||||
}
|
||||
|
||||
export function isDesktopSlashSuggestion(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
/** Gates execution: true unless the command is a known no-desktop-surface command. */
|
||||
export function isDesktopSlashCommand(command: string): boolean {
|
||||
const spec = resolveDesktopCommand(command)
|
||||
|
||||
// Surface skill / quick commands (extensions the backend provides) alongside
|
||||
// the curated built-ins. Built-in aliases stay hidden so the popover isn't
|
||||
// cluttered with duplicates.
|
||||
if (isDesktopSlashExtensionCommand(normalized)) {
|
||||
return true
|
||||
if (spec) {
|
||||
return spec.surface.kind !== 'unavailable'
|
||||
}
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
|
||||
return isDesktopSlashExtensionCommand(command)
|
||||
}
|
||||
|
||||
/** Gates discovery in the popover/completions. */
|
||||
export function isDesktopSlashSuggestion(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
|
||||
// Aliases stay hidden so the popover isn't cluttered with duplicates.
|
||||
if (ALIAS_TO_CANONICAL.has(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const spec = SPEC_BY_NAME.get(normalized)
|
||||
|
||||
if (spec) {
|
||||
return spec.surface.kind !== 'unavailable' && !spec.hidden
|
||||
}
|
||||
|
||||
// Skill / quick commands the backend provides.
|
||||
return isDesktopSlashExtensionCommand(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for commands the desktop fulfils by opening the model picker overlay
|
||||
* (e.g. `/model`) rather than executing a slash command. The caller opens the
|
||||
* picker UI instead of printing the "uses the desktop model picker" notice.
|
||||
* True for commands the desktop fulfils by opening an overlay picker
|
||||
* (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker.
|
||||
*/
|
||||
export function isModelPickerCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean {
|
||||
const surface = resolveDesktopCommand(command)?.surface
|
||||
|
||||
return PICKER_OWNED_COMMANDS.has(canonical)
|
||||
if (surface?.kind !== 'picker') {
|
||||
return false
|
||||
}
|
||||
|
||||
return picker ? surface.picker === picker : true
|
||||
}
|
||||
|
||||
/** Back-compat shim for the model picker check. */
|
||||
export function isModelPickerCommand(command: string): boolean {
|
||||
return isPickerCommand(command, 'model')
|
||||
}
|
||||
|
||||
export function desktopSlashUnavailableMessage(command: string): string | null {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
const canonical = canonicalDesktopSlashCommand(command)
|
||||
const surface = SPEC_BY_NAME.get(canonical)?.surface
|
||||
|
||||
if (PICKER_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
|
||||
if (!surface) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
|
||||
if (surface.kind === 'unavailable') {
|
||||
return UNAVAILABLE_MESSAGE[surface.reason](canonical)
|
||||
}
|
||||
|
||||
if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only used from messaging platforms.`
|
||||
}
|
||||
|
||||
if (ADVANCED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
|
||||
}
|
||||
|
||||
if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only available in the terminal interface.`
|
||||
if (surface.kind === 'picker') {
|
||||
return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function desktopSlashDescription(command: string, fallback = ''): string {
|
||||
const canonical = canonicalDesktopSlashCommand(command)
|
||||
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback
|
||||
}
|
||||
|
||||
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
|
||||
/**
|
||||
* True when picking the bare command should expand to its inline argument
|
||||
* options (theme / personality / session / platform / toolset) rather than
|
||||
* committing immediately. Lets the popover act as a two-step picker.
|
||||
*/
|
||||
export function desktopSlashCommandTakesArgs(command: string): boolean {
|
||||
return resolveDesktopCommand(command)?.args ?? false
|
||||
}
|
||||
|
||||
export function desktopSkinSlashCompletions(
|
||||
|
|
@ -274,13 +345,36 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
|
|||
?.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||
|
||||
// Recount skill commands from the filtered output so /help's footer reflects
|
||||
// what the user actually sees. Backend's skill_count includes commands the
|
||||
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
|
||||
// like "60 skill commands available" while only ~29 appear in the list.
|
||||
const filteredCommands = new Set<string>()
|
||||
|
||||
for (const section of categories ?? []) {
|
||||
for (const [command] of section.pairs) {
|
||||
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [command] of pairs ?? []) {
|
||||
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||
}
|
||||
|
||||
let skillCount = 0
|
||||
|
||||
for (const command of filteredCommands) {
|
||||
if (isDesktopSlashExtensionCommand(command)) {
|
||||
skillCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
|
||||
|
||||
return {
|
||||
...catalog,
|
||||
...(categories ? { categories } : {}),
|
||||
...(pairs ? { pairs } : {})
|
||||
...(pairs ? { pairs } : {}),
|
||||
...(hasSkillCount ? { skill_count: skillCount } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownHermesSlashCommand(command: string): boolean {
|
||||
return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||
import {
|
||||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
removeComposerAttachment,
|
||||
SESSION_DRAFTS_STORAGE_KEY,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft,
|
||||
updateComposerAttachment
|
||||
} from './composer'
|
||||
|
||||
|
|
@ -41,3 +45,62 @@ describe('updateComposerAttachment', () => {
|
|||
expect($composerAttachments.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session drafts', () => {
|
||||
afterEach(() => {
|
||||
for (const scope of ['session-a', 'session-b', null]) {
|
||||
clearSessionDraft(scope)
|
||||
}
|
||||
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('keeps drafts isolated per session scope', () => {
|
||||
stashSessionDraft('session-a', 'draft a', [])
|
||||
stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])
|
||||
|
||||
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: 'draft a' })
|
||||
expect(takeSessionDraft('session-b').text).toBe('draft b')
|
||||
expect(takeSessionDraft('session-b').attachments.map(a => a.id)).toEqual(['image:b'])
|
||||
})
|
||||
|
||||
it('scopes the unsaved new-session draft separately from real sessions', () => {
|
||||
stashSessionDraft(null, 'new chat draft', [])
|
||||
stashSessionDraft('session-a', 'session draft', [])
|
||||
|
||||
expect(takeSessionDraft(null).text).toBe('new chat draft')
|
||||
expect(takeSessionDraft(undefined).text).toBe('new chat draft')
|
||||
expect(takeSessionDraft('session-a').text).toBe('session draft')
|
||||
})
|
||||
|
||||
it('persists draft text (not attachments) to localStorage', () => {
|
||||
stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
|
||||
|
||||
const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record<string, string>
|
||||
|
||||
expect(persisted['session-a']).toBe('survives reload')
|
||||
})
|
||||
|
||||
it('evicts empty drafts instead of leaving stale entries behind', () => {
|
||||
stashSessionDraft('session-a', 'saved', [])
|
||||
stashSessionDraft('session-a', ' ', [])
|
||||
|
||||
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
|
||||
})
|
||||
|
||||
it('clears a stashed draft after an accepted submit', () => {
|
||||
stashSessionDraft('session-a', 'sent prompt', [attachment({ id: 'file:a' })])
|
||||
clearSessionDraft('session-a')
|
||||
|
||||
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
|
||||
})
|
||||
|
||||
it('returns clones so callers cannot mutate the stash', () => {
|
||||
stashSessionDraft('session-a', 'draft', [attachment({ id: 'file:a' })])
|
||||
|
||||
const taken = takeSessionDraft('session-a')
|
||||
taken.attachments[0]!.label = 'mutated'
|
||||
|
||||
expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,84 @@ export const $composerDraft = atom('')
|
|||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
export const $composerTerminalSelections = atom<Record<string, string>>({})
|
||||
|
||||
// Per-thread draft stash for the decoupled composer. Session lifecycle never
|
||||
// touches this — only ChatBar's scope swap reads/writes it. Text mirrors to
|
||||
// localStorage; attachments are memory-only (blobs, upload state).
|
||||
export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3'
|
||||
|
||||
const NEW_SESSION_DRAFT_KEY = '__new__'
|
||||
const MAX_PERSISTED_DRAFTS = 50
|
||||
const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' }
|
||||
|
||||
export interface SessionDraft {
|
||||
attachments: ComposerAttachment[]
|
||||
text: string
|
||||
}
|
||||
|
||||
const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY
|
||||
|
||||
const cloneDraft = (draft: SessionDraft): SessionDraft => ({
|
||||
attachments: draft.attachments.map(attachment => ({ ...attachment })),
|
||||
text: draft.text
|
||||
})
|
||||
|
||||
function loadPersistedDraftTexts(): [string, SessionDraft][] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(JSON.parse(raw) as Record<string, string>).map(([key, text]) => [
|
||||
key,
|
||||
{ attachments: [], text }
|
||||
])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
|
||||
|
||||
function persistDraftTexts() {
|
||||
try {
|
||||
const entries = [...draftsBySession]
|
||||
.filter(([, draft]) => draft.text)
|
||||
.slice(-MAX_PERSISTED_DRAFTS)
|
||||
.map(([key, draft]) => [key, draft.text] as const)
|
||||
|
||||
if (entries.length === 0) {
|
||||
window.localStorage.removeItem(SESSION_DRAFTS_STORAGE_KEY)
|
||||
} else {
|
||||
window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only — quota/private-mode must never break typing.
|
||||
}
|
||||
}
|
||||
|
||||
export function stashSessionDraft(scope: string | null | undefined, text: string, attachments: ComposerAttachment[]) {
|
||||
const key = draftKey(scope)
|
||||
|
||||
// Delete-then-set keeps MRU order for MAX_PERSISTED_DRAFTS eviction.
|
||||
draftsBySession.delete(key)
|
||||
|
||||
if (text.trim() || attachments.length > 0) {
|
||||
draftsBySession.set(key, cloneDraft({ attachments, text }))
|
||||
}
|
||||
|
||||
persistDraftTexts()
|
||||
}
|
||||
|
||||
export function takeSessionDraft(scope: string | null | undefined): SessionDraft {
|
||||
const stashed = draftsBySession.get(draftKey(scope))
|
||||
|
||||
return stashed ? cloneDraft(stashed) : EMPTY_SESSION_DRAFT
|
||||
}
|
||||
|
||||
export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,13 +133,52 @@ describe('mergeSessionPage', () => {
|
|||
it('keeps a pinned session matched by its lineage root after compression', () => {
|
||||
// The pin is stored on the lineage-root id, but the loaded row surfaces
|
||||
// under its live compression tip. Matching on _lineage_root_id keeps it.
|
||||
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })]
|
||||
const incoming = [session({ id: 'other' })]
|
||||
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] as SessionInfo[]
|
||||
const incoming = [session({ id: 'other' })] as SessionInfo[]
|
||||
|
||||
const merged = mergeSessionPage(previous, incoming, ['root'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
|
||||
})
|
||||
|
||||
it('evicts an old compression tip when the incoming page has the new tip from the same lineage', () => {
|
||||
// Repro of #43483: after auto-compression rotates the tip (#4 → #5),
|
||||
// the sidebar showed both the old tip and the new tip as separate rows.
|
||||
// The old tip must be evicted because its lineage key matches the incoming
|
||||
// new tip's lineage key.
|
||||
const previous = [
|
||||
session({ id: 'tip-4', _lineage_root_id: 'root' }),
|
||||
session({ id: 'other' }),
|
||||
] as SessionInfo[]
|
||||
const incoming = [
|
||||
session({ id: 'tip-5', _lineage_root_id: 'root' }),
|
||||
] as SessionInfo[]
|
||||
|
||||
// 'tip-4' is in the keep set (e.g. it was the active/working session),
|
||||
// but should still be evicted because the incoming page carries the same
|
||||
// lineage under a new tip id.
|
||||
const merged = mergeSessionPage(previous, incoming, ['tip-4'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['tip-5'])
|
||||
// The new tip comes from the server payload.
|
||||
expect(merged.find(s => s.id === 'tip-5')?._lineage_root_id).toBe('root')
|
||||
})
|
||||
|
||||
it('preserves an unrelated pinned session even when lineage dedup is active', () => {
|
||||
// Regression guard: lineage dedup must not accidentally evict sessions
|
||||
// from a different lineage that happen to be in the keep set.
|
||||
const previous = [
|
||||
session({ id: 'a-old', _lineage_root_id: 'lineage-a' }),
|
||||
session({ id: 'b', _lineage_root_id: 'lineage-b' }),
|
||||
] as SessionInfo[]
|
||||
const incoming = [
|
||||
session({ id: 'a-new', _lineage_root_id: 'lineage-a' }),
|
||||
] as SessionInfo[]
|
||||
|
||||
const merged = mergeSessionPage(previous, incoming, ['b'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceCwdForNewSession', () => {
|
||||
|
|
|
|||
|
|
@ -125,10 +125,18 @@ export function mergeSessionPage(
|
|||
}
|
||||
|
||||
const incomingIds = new Set(incoming.map(session => session.id))
|
||||
// Deduplicate by compression lineage: when auto-compression rotates the tip
|
||||
// id (old #4 → new #5), the incoming page carries the new tip but the
|
||||
// previous list still holds the old one. Without lineage-level dedup both
|
||||
// rows survive as separate sidebar entries (fixes #43483).
|
||||
const incomingLineageKeys = new Set(
|
||||
incoming.map(session => session._lineage_root_id ?? session.id)
|
||||
)
|
||||
|
||||
const survivors = previous.filter(
|
||||
session =>
|
||||
!incomingIds.has(session.id) &&
|
||||
!incomingLineageKeys.has(session._lineage_root_id ?? session.id) &&
|
||||
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
|
||||
)
|
||||
|
||||
|
|
@ -200,6 +208,7 @@ export const $availablePersonalities = atom<string[]>([])
|
|||
export const $introSeed = atom(0)
|
||||
export const $contextSuggestions = atom<ContextSuggestion[]>([])
|
||||
export const $modelPickerOpen = atom(false)
|
||||
export const $sessionPickerOpen = atom(false)
|
||||
|
||||
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
|
||||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
|
|
@ -249,6 +258,7 @@ export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom
|
|||
export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, next)
|
||||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
export const setSessionPickerOpen = (next: Updater<boolean>) => updateAtom($sessionPickerOpen, next)
|
||||
|
||||
// Watchdog tracking — when does a "working" session count as stuck?
|
||||
// Long-running tool calls (LLM inference, long shell commands, web fetches)
|
||||
|
|
|
|||
97
cli.py
97
cli.py
|
|
@ -3426,6 +3426,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
# frozen when the agent thread completes, displayed in the status bar.
|
||||
self._prompt_start_time: Optional[float] = None # time.time() when turn started
|
||||
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
|
||||
self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
|
||||
# Initialize SQLite session store early so /title works before first message
|
||||
self._session_db = None
|
||||
try:
|
||||
|
|
@ -3812,6 +3813,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
emoji = "⏱" if live else "⏲"
|
||||
return f"{emoji} {time_str}"
|
||||
|
||||
@staticmethod
|
||||
def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
|
||||
"""Format time since the last final agent response for the status bar.
|
||||
|
||||
Returns an empty string while a turn is live (the per-prompt elapsed
|
||||
timer covers that case) or before the first turn has completed.
|
||||
Compact read-out: ``✓ 42s`` / ``✓ 3m`` / ``✓ 1h 12m``.
|
||||
"""
|
||||
if turn_live or last_finished_at is None:
|
||||
return ""
|
||||
idle = max(0.0, time.time() - last_finished_at)
|
||||
return f"✓ {format_duration_compact(idle)}"
|
||||
|
||||
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
||||
# Prefer the agent's model name — it updates on fallback.
|
||||
# self.model reflects the originally configured model and never
|
||||
|
|
@ -3835,6 +3849,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
getattr(self, "_prompt_duration", 0.0),
|
||||
live=getattr(self, "_prompt_start_time", None) is not None,
|
||||
),
|
||||
"idle_since": self._format_idle_since(
|
||||
getattr(self, "_last_turn_finished_at", None),
|
||||
turn_live=getattr(self, "_prompt_start_time", None) is not None,
|
||||
),
|
||||
"context_tokens": 0,
|
||||
"context_length": None,
|
||||
"context_percent": None,
|
||||
|
|
@ -4146,6 +4164,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
prompt_elapsed = snapshot.get("prompt_elapsed")
|
||||
if prompt_elapsed:
|
||||
parts.append(prompt_elapsed)
|
||||
idle_since = snapshot.get("idle_since")
|
||||
if idle_since:
|
||||
parts.append(idle_since)
|
||||
if yolo_active:
|
||||
parts.append("⚠ YOLO")
|
||||
return self._trim_status_bar_text(" │ ".join(parts), width)
|
||||
|
|
@ -4247,6 +4268,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
if prompt_elapsed:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("class:status-bar-dim", prompt_elapsed))
|
||||
# Position 8: idle time since the last final agent response
|
||||
idle_since = snapshot.get("idle_since")
|
||||
if idle_since:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("class:status-bar-dim", idle_since))
|
||||
if yolo_active:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
|
||||
|
|
@ -5552,6 +5578,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
|
||||
)
|
||||
|
||||
quick_commands = self.config.get("quick_commands", {})
|
||||
if quick_commands:
|
||||
_cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
|
||||
for name, qcmd in sorted(quick_commands.items()):
|
||||
desc = qcmd.get("description", qcmd.get("type", ""))
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}"
|
||||
)
|
||||
|
||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
|
||||
|
|
@ -5821,6 +5856,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
|
||||
"""Drop a just-ended session row when it never gained content.
|
||||
|
||||
Starting the CLI and immediately quitting (or rotating with /new,
|
||||
/clear) used to leave an empty untitled row behind that clutters
|
||||
``/resume`` and ``hermes sessions list``. Delegates the
|
||||
check-and-delete to ``SessionDB.delete_session_if_empty``, which
|
||||
only removes rows with no messages, no title, and no child
|
||||
sessions. Ported from google-gemini/gemini-cli#27770.
|
||||
"""
|
||||
if not self._session_db or not session_id:
|
||||
return False
|
||||
# In-memory transcript is authoritative: if this CLI object holds
|
||||
# conversation messages (flushed to the DB or not), the session is
|
||||
# not empty. Protects against pruning a real conversation whose DB
|
||||
# flush failed or hasn't happened yet.
|
||||
if getattr(self, "conversation_history", None):
|
||||
return False
|
||||
try:
|
||||
from hermes_constants import get_hermes_home as _ghh
|
||||
return self._session_db.delete_session_if_empty(
|
||||
session_id, sessions_dir=_ghh() / "sessions"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not prune empty session %s", session_id, exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
def new_session(self, silent=False, title=None):
|
||||
"""Start a fresh session with a new session ID and cleared agent state."""
|
||||
if self.agent and self.conversation_history:
|
||||
|
|
@ -5837,6 +5901,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
self._session_db.end_session(old_session_id, "new_session")
|
||||
except Exception:
|
||||
pass
|
||||
# Don't let immediately-rotated empty sessions pile up in
|
||||
# /resume and `hermes sessions list` (gemini-cli#27770 port).
|
||||
self._discard_session_if_empty(old_session_id)
|
||||
|
||||
self.session_start = datetime.now()
|
||||
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
|
||||
|
|
@ -10121,6 +10188,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
if self._prompt_start_time is not None:
|
||||
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
|
||||
self._prompt_start_time = None
|
||||
# Record when this agent loop finished so the status bar can show
|
||||
# idle time since the last final response.
|
||||
self._last_turn_finished_at = time.time()
|
||||
|
||||
# Proactively clean up async clients whose event loop is dead.
|
||||
# The agent thread may have created AsyncOpenAI clients bound
|
||||
|
|
@ -13074,6 +13144,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
self._session_db.end_session(self.agent.session_id, "cli_close")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not close session in DB: %s", e)
|
||||
# Started-and-immediately-quit sessions never gained content;
|
||||
# drop the empty row so /resume and `hermes sessions list`
|
||||
# stay clean (gemini-cli#27770 port). No-op for resumed or
|
||||
# titled sessions and anything with messages or children.
|
||||
if not getattr(self, '_delete_session_on_exit', False):
|
||||
try:
|
||||
self._discard_session_if_empty(self.agent.session_id)
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not prune empty session: %s", e)
|
||||
# /exit --delete: also remove the current session's transcripts
|
||||
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
|
||||
if getattr(self, '_delete_session_on_exit', False):
|
||||
|
|
@ -13336,9 +13415,21 @@ def main(
|
|||
else:
|
||||
toolsets_list.append(str(t))
|
||||
else:
|
||||
# Use the shared resolver so MCP servers are included at runtime
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
|
||||
# Coding posture (base Hermes): with no explicit --toolsets, collapse
|
||||
# to the coding toolset (+ enabled MCP servers) when sitting in a code
|
||||
# workspace. See agent/coding_context.py.
|
||||
_coding = None
|
||||
try:
|
||||
from agent.coding_context import coding_selection
|
||||
_coding = coding_selection(platform="cli", config=CLI_CONFIG)
|
||||
except Exception:
|
||||
_coding = None
|
||||
if _coding is not None:
|
||||
toolsets_list = _coding
|
||||
else:
|
||||
# Use the shared resolver so MCP servers are included at runtime
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
|
||||
|
||||
parsed_skills = _parse_skills_argument(skills)
|
||||
|
||||
|
|
|
|||
44
cron/jobs.py
44
cron/jobs.py
|
|
@ -150,9 +150,6 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:
|
|||
state = "scheduled" if normalized.get("enabled", True) else "paused"
|
||||
normalized["state"] = state
|
||||
|
||||
profile = _coerce_job_text(normalized.get("profile")).strip()
|
||||
normalized["profile"] = profile or None
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
|
|
@ -523,30 +520,6 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
|
|||
return str(resolved)
|
||||
|
||||
|
||||
def _normalize_profile(profile: Optional[str]) -> Optional[str]:
|
||||
"""Normalize and validate an optional cron job profile name.
|
||||
|
||||
Empty / None disables per-job profile selection. Otherwise the profile name
|
||||
is canonicalized with the same rules as ``hermes -p`` and must refer to an
|
||||
existing profile at create/update time. ``default`` is the built-in root
|
||||
profile and is always valid.
|
||||
"""
|
||||
if profile is None:
|
||||
return None
|
||||
raw = str(profile).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
|
||||
|
||||
normalized = normalize_profile_name(raw)
|
||||
# resolve_profile_env validates the canonical name and checks that named
|
||||
# profiles exist. Store only the stable profile id, not the filesystem path,
|
||||
# so profile directories can move with the Hermes root.
|
||||
resolve_profile_env(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def create_job(
|
||||
prompt: Optional[str],
|
||||
schedule: str,
|
||||
|
|
@ -563,7 +536,6 @@ def create_job(
|
|||
context_from: Optional[Union[str, List[str]]] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
workdir: Optional[str] = None,
|
||||
profile: Optional[str] = None,
|
||||
no_agent: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -605,11 +577,6 @@ def create_job(
|
|||
With ``no_agent=True``, ``workdir`` is still applied as the
|
||||
script's cwd so relative paths inside the script behave
|
||||
predictably.
|
||||
profile: Optional Hermes profile name. When set, the job runs with
|
||||
that profile's HERMES_HOME so profile-specific config,
|
||||
credentials, scripts, skills, and memory paths resolve
|
||||
consistently. ``default`` selects the root profile; empty /
|
||||
None preserves the scheduler's existing behaviour.
|
||||
no_agent: When True, skip the agent entirely — run ``script`` on schedule
|
||||
and deliver its stdout directly. Empty stdout = silent (no
|
||||
delivery). Requires ``script`` to be set. Ideal for classic
|
||||
|
|
@ -647,7 +614,6 @@ def create_job(
|
|||
normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None
|
||||
normalized_toolsets = normalized_toolsets or None
|
||||
normalized_workdir = _normalize_workdir(workdir)
|
||||
normalized_profile = _normalize_profile(profile)
|
||||
normalized_no_agent = bool(no_agent)
|
||||
|
||||
# no_agent jobs are meaningless without a script — the script IS the job.
|
||||
|
|
@ -702,7 +668,6 @@ def create_job(
|
|||
"origin": origin, # Tracks where job was created for "origin" delivery
|
||||
"enabled_toolsets": normalized_toolsets,
|
||||
"workdir": normalized_workdir,
|
||||
"profile": normalized_profile,
|
||||
}
|
||||
|
||||
jobs = load_jobs()
|
||||
|
|
@ -792,15 +757,6 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
|||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
|
||||
# Validate / normalize profile if present in updates. Empty string or
|
||||
# None both mean "clear the field" (restore old behaviour).
|
||||
if "profile" in updates:
|
||||
_profile = updates["profile"]
|
||||
if _profile is None or _profile == "" or _profile is False:
|
||||
updates["profile"] = None
|
||||
else:
|
||||
updates["profile"] = _normalize_profile(_profile)
|
||||
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import shutil
|
|||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
# fcntl is Unix-only; on Windows use msvcrt for file locking
|
||||
try:
|
||||
|
|
@ -167,7 +166,7 @@ _parallel_pool_max_workers: Optional[int] = None
|
|||
_running_job_ids: set = set()
|
||||
_running_lock = threading.Lock()
|
||||
|
||||
# Sequential (env/context-mutating) cron jobs — workdir/profile jobs that touch
|
||||
# Sequential (env-mutating) cron jobs — workdir jobs that touch
|
||||
# process-global runtime state — must run one at a time, but must NOT block the
|
||||
# ticker thread. A persistent single-thread executor preserves ordering across
|
||||
# ticks while keeping dispatch fire-and-forget, the same as the parallel pool.
|
||||
|
|
@ -191,10 +190,10 @@ def _get_parallel_pool(max_workers: Optional[int]) -> concurrent.futures.ThreadP
|
|||
def _get_sequential_pool() -> concurrent.futures.ThreadPoolExecutor:
|
||||
"""Return (or create) the persistent single-thread sequential pool.
|
||||
|
||||
A single worker guarantees env/context-mutating jobs never overlap, even
|
||||
A single worker guarantees env-mutating jobs never overlap, even
|
||||
across ticks: a job queued by a newer tick waits for the previous tick's
|
||||
sequential jobs to finish rather than corrupting their os.environ /
|
||||
profile state.
|
||||
sequential jobs to finish rather than corrupting their os.environ
|
||||
state.
|
||||
"""
|
||||
global _sequential_pool
|
||||
if _sequential_pool is None:
|
||||
|
|
@ -236,71 +235,6 @@ def _get_lock_paths() -> tuple[Path, Path]:
|
|||
return lock_dir, lock_dir / ".tick.lock"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _job_profile_context(job_id: str, profile: Optional[str]):
|
||||
"""Temporarily run a job under a specific Hermes profile.
|
||||
|
||||
Cron jobs are stored and scheduled by the profile running the scheduler, but
|
||||
an individual job can opt into a different runtime profile. While active,
|
||||
the scheduler's test/override hook and a context-local Hermes home override
|
||||
both point at the resolved profile directory so _get_hermes_home(),
|
||||
.env/config loading, script resolution, AIAgent construction, and downstream
|
||||
get_hermes_home() callers agree on the same home.
|
||||
|
||||
Some existing provider/config paths still load profile .env values through
|
||||
os.environ, so profile jobs also snapshot and restore the process
|
||||
environment on exit. tick() runs profile jobs sequentially to keep that
|
||||
temporary mutation isolated from other scheduled jobs.
|
||||
"""
|
||||
raw_profile = str(profile or "").strip()
|
||||
if not raw_profile:
|
||||
yield None
|
||||
return
|
||||
|
||||
global _hermes_home
|
||||
prior_override = _hermes_home
|
||||
env_snapshot = os.environ.copy()
|
||||
|
||||
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
normalized_profile = normalize_profile_name(raw_profile)
|
||||
try:
|
||||
profile_home = Path(resolve_profile_env(normalized_profile)).resolve()
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Job '%s': configured profile %r no longer valid (%s) — "
|
||||
"falling back to scheduler default",
|
||||
job_id, raw_profile, exc,
|
||||
)
|
||||
yield None
|
||||
return
|
||||
|
||||
override_token = None
|
||||
try:
|
||||
override_token = set_hermes_home_override(profile_home)
|
||||
_hermes_home = profile_home
|
||||
logger.info(
|
||||
"Job '%s': using Hermes profile '%s' (%s)",
|
||||
job_id,
|
||||
normalized_profile,
|
||||
profile_home,
|
||||
)
|
||||
yield normalized_profile
|
||||
finally:
|
||||
_hermes_home = prior_override
|
||||
if override_token is not None:
|
||||
reset_hermes_home_override(override_token)
|
||||
# Delta-based restore: remove added keys, restore changed keys.
|
||||
# Avoids a brief window where other threads see an empty env.
|
||||
added = set(os.environ.keys()) - set(env_snapshot.keys())
|
||||
for k in added:
|
||||
os.environ.pop(k, None)
|
||||
for k, v in env_snapshot.items():
|
||||
if os.environ.get(k) != v:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _resolve_origin(job: dict) -> Optional[dict]:
|
||||
"""Extract origin info from a job, preserving any extra routing metadata.
|
||||
|
||||
|
|
@ -1033,17 +967,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
|||
else:
|
||||
argv = [sys.executable, str(path)]
|
||||
|
||||
run_env = os.environ.copy()
|
||||
run_env["HERMES_HOME"] = str(_get_hermes_home())
|
||||
try:
|
||||
from hermes_constants import get_subprocess_home
|
||||
|
||||
profile_home = get_subprocess_home()
|
||||
if profile_home:
|
||||
run_env["HOME"] = profile_home
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
|
||||
result = subprocess.run(
|
||||
|
|
@ -1052,7 +975,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
|||
text=True,
|
||||
timeout=script_timeout,
|
||||
cwd=str(path.parent),
|
||||
env=run_env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
stdout = (result.stdout or "").strip()
|
||||
|
|
@ -1382,13 +1304,6 @@ def _scan_assembled_cron_prompt(
|
|||
|
||||
|
||||
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"""Execute a single cron job, applying any per-job profile override."""
|
||||
job_id = job["id"]
|
||||
with _job_profile_context(job_id, job.get("profile")):
|
||||
return _run_job_impl(job)
|
||||
|
||||
|
||||
def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"""
|
||||
Execute a single cron job.
|
||||
|
||||
|
|
@ -1625,9 +1540,8 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
# .cursorrules from the job's project dir, AND
|
||||
# - the terminal, file, and code-exec tools run commands from there.
|
||||
#
|
||||
# tick() serializes jobs that mutate process-global runtime state (workdir
|
||||
# and/or profile jobs) outside the parallel pool, so mutating
|
||||
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
|
||||
# tick() serializes workdir-jobs outside the parallel pool, so mutating
|
||||
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
|
||||
# jobs we leave TERMINAL_CWD untouched — preserves the original behaviour
|
||||
# (skip_context_files=True, tools use whatever cwd the scheduler has).
|
||||
_job_workdir = (job.get("workdir") or "").strip() or None
|
||||
|
|
@ -2174,21 +2088,12 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
|
|||
mark_job_run(job["id"], False, str(e))
|
||||
return False
|
||||
|
||||
# Partition due jobs: jobs with a per-job workdir and/or profile touch
|
||||
# process-global runtime state inside run_job. Workdir jobs temporarily
|
||||
# set os.environ["TERMINAL_CWD"]; profile jobs use a context-local
|
||||
# Hermes home override, scheduler _hermes_home hook, and temporary
|
||||
# profile .env load into os.environ with snapshot/restore. They MUST run
|
||||
# sequentially to avoid corrupting each other. Jobs without either field
|
||||
# stay parallel-safe.
|
||||
sequential_jobs = [
|
||||
j for j in due_jobs
|
||||
if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
|
||||
]
|
||||
parallel_jobs = [
|
||||
j for j in due_jobs
|
||||
if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
|
||||
]
|
||||
# Partition due jobs: those with a per-job workdir mutate
|
||||
# os.environ["TERMINAL_CWD"] inside run_job, which is process-global —
|
||||
# so they MUST run sequentially to avoid corrupting each other. Jobs
|
||||
# without a workdir leave env untouched and stay parallel-safe.
|
||||
sequential_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()]
|
||||
parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()]
|
||||
|
||||
_results: list = []
|
||||
_all_futures: list = []
|
||||
|
|
@ -2217,9 +2122,9 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
|
|||
|
||||
return pool.submit(_run_and_release)
|
||||
|
||||
# Sequential pass for env/context-mutating (workdir/profile) jobs.
|
||||
# Sequential pass for env-mutating (workdir) jobs.
|
||||
# Queued to a persistent single-thread pool so they run one at a time
|
||||
# WITHOUT blocking the ticker thread — a long workdir/profile job no
|
||||
# WITHOUT blocking the ticker thread — a long workdir job no
|
||||
# longer starves the rest of the schedule (same fix as the parallel
|
||||
# pass, just serialized). The in-flight guard prevents a still-running
|
||||
# job from being re-queued on the next tick.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
---
|
||||
title: "fix: Prevent Telegram streamed replies from ending after first overflow chunk"
|
||||
status: active
|
||||
date: 2026-06-09
|
||||
type: fix
|
||||
target_repo: hermes-agent
|
||||
origin: user-reported Telegram topic screenshot
|
||||
---
|
||||
|
||||
# fix: Prevent Telegram streamed replies from ending after first overflow chunk
|
||||
|
||||
## Summary
|
||||
|
||||
Fix a Telegram gateway bug where a long streamed assistant reply can appear to stop mid-answer in a topic after the first overflow chunk. The reported screenshot shows a long Hermes response in the `Nehemiah - Coding` Telegram topic ending at `- The visible tool-call summary`, followed by the user noting that the previous message did not finish streaming to that Telegram topic.
|
||||
|
||||
The plan targets the streamed edit overflow path, not general model generation. A completed assistant response must either reach Telegram in full across all continuation messages or leave enough state for the gateway fallback path to deliver the remaining content instead of marking the turn complete after a partial delivery.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Telegram limits message text to 4096 UTF-16 code units. Hermes streams gateway responses by editing a message and, when a streamed message grows past the limit, splitting the overflow into additional Telegram messages. The adapter already has a split-and-deliver path for oversized edits, but the partial-continuation failure contract is weak: if chunk 1 is edited successfully and a later continuation fails, the adapter can still report success for the operation. The stream consumer may then mark the final response delivered even though the visible topic only contains the first part.
|
||||
|
||||
This is especially visible in Telegram forum topics because a long final response can be split below tool-progress bubbles, and a missing continuation looks exactly like the stream stopped mid-answer.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1. Long streamed Telegram replies must preserve all final content across overflow chunks.
|
||||
- R2. If any continuation chunk fails after the first overflow edit lands, the gateway must not mark the final response as fully delivered.
|
||||
- R3. Continuation chunks must remain routed to the same Telegram topic/thread as the original response.
|
||||
- R4. The fix must avoid duplicate full-answer sends when all overflow chunks were delivered successfully.
|
||||
- R5. Tests must cover the reported failure shape: a final streamed reply that exceeds Telegram's limit, succeeds on the first edit, fails on a continuation, and must not be treated as complete.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- Treat overflow delivery as all-or-not-complete. `_edit_overflow_split` should only return a successful final-delivery result when every planned chunk reaches Telegram. Partial delivery is a distinct outcome that downstream code can recover from.
|
||||
- Carry partial-overflow metadata through `SendResult.raw_response` rather than adding a new public dataclass field unless implementation proves the existing result shape is insufficient. The stream consumer already inspects `SendResult` after adapter edits, so a small raw response contract can keep the change contained.
|
||||
- Make the stream consumer responsible for final-delivery truth. The adapter knows which chunks landed, but the consumer owns `_final_response_sent`, `_final_content_delivered`, `_fallback_prefix`, and fallback final-send behaviour.
|
||||
- Keep routing inside Telegram adapter helpers. Continuation sends should continue to use `_thread_kwargs_for_send(...)` with metadata-derived `message_thread_id` and reply anchors so forum topic behaviour stays consistent.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as GatewayStreamConsumer
|
||||
participant T as TelegramAdapter.edit_message
|
||||
participant B as Telegram Bot API
|
||||
|
||||
C->>T: finalize/edit long accumulated response
|
||||
T->>B: edit original message with chunk 1
|
||||
loop remaining chunks
|
||||
T->>B: send continuation in same topic/thread
|
||||
end
|
||||
alt all chunks delivered
|
||||
T-->>C: success, last message id, continuation ids
|
||||
C->>C: mark final response delivered
|
||||
else any continuation failed
|
||||
T-->>C: partial overflow failure with delivered prefix metadata
|
||||
C->>C: do not mark final delivered
|
||||
C->>B: fallback sends missing tail or full final response safely
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Add a partial-overflow contract for Telegram edit splits
|
||||
|
||||
**Goal:** Make `TelegramAdapter._edit_overflow_split` distinguish complete overflow delivery from partial delivery.
|
||||
|
||||
**Requirements:** R1, R2, R4
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- `gateway/platforms/telegram.py`
|
||||
- `tests/gateway/test_telegram_send.py` or the existing Telegram adapter test module that already covers `edit_message` overflow behaviour
|
||||
|
||||
**Approach:**
|
||||
- Keep the successful path unchanged when every chunk is delivered: return `SendResult(success=True, message_id=<last chunk>, continuation_message_ids=(...))`.
|
||||
- When a continuation fails after the first edit, return a result that clearly indicates partial delivery instead of plain success. Prefer `success=False`, `retryable=True`, and `raw_response` metadata such as delivered chunk count, total chunk count, last delivered message id, and the visible delivered prefix.
|
||||
- Preserve logging, but do not rely on logs as the only signal. The caller must be able to tell partial delivery happened.
|
||||
- Ensure the first edited chunk and all successful continuation chunks still include the existing Markdown/plain-text fallback behaviour.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing overflow handling in `TelegramAdapter.edit_message` and `_edit_overflow_split`.
|
||||
- Existing `SendResult` semantics in `gateway/platforms/base.py`, especially `retryable`, `raw_response`, and `continuation_message_ids`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Oversized finalized edit where all continuations succeed returns success, the last continuation id, and all continuation ids.
|
||||
- Oversized finalized edit where the first continuation send fails returns a partial-overflow failure and does not report success.
|
||||
- Oversized finalized edit where one continuation succeeds and a later continuation fails reports the last delivered continuation id and delivered count in raw metadata.
|
||||
- A continuation MarkdownV2 formatting failure still retries plain text before being treated as a delivery failure.
|
||||
|
||||
**Verification:** Adapter tests prove complete overflow remains successful and partial overflow is observable by the caller.
|
||||
|
||||
### U2. Teach the stream consumer to recover from partial overflow
|
||||
|
||||
**Goal:** Ensure a partial Telegram overflow does not set `_final_response_sent` or `_final_content_delivered` unless the full response reached the user.
|
||||
|
||||
**Requirements:** R1, R2, R4, R5
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `gateway/stream_consumer.py`
|
||||
- `tests/gateway/test_stream_consumer.py` or a focused new `tests/gateway/test_stream_consumer_telegram_overflow.py`
|
||||
|
||||
**Approach:**
|
||||
- In `_send_or_edit`, when `adapter.edit_message(...)` returns a partial-overflow failure, update consumer state to reflect the last visible prefix/message and enter fallback delivery for the missing content.
|
||||
- Avoid treating `_already_sent` as final delivery. A partial visible message can be true while final delivery is false.
|
||||
- Use the delivered-prefix metadata if available so `_send_fallback_final(...)` sends only the missing tail. If implementation finds the prefix is unreliable after Markdown formatting, prefer sending the complete final response as a fresh fallback message rather than silently dropping the tail.
|
||||
- Keep the existing success handling for `continuation_message_ids` when the adapter delivered all chunks.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing fallback mode in `GatewayStreamConsumer._send_or_edit` and `_send_fallback_final`.
|
||||
- Existing comments around `_final_response_sent`, `_final_content_delivered`, and `_fallback_prefix` for prior partial-delivery regressions.
|
||||
|
||||
**Test scenarios:**
|
||||
- A final streamed response that overflows and receives a complete-success edit split sets final-delivery flags and does not invoke fallback.
|
||||
- A final streamed response whose adapter reports partial overflow does not set final-delivery flags immediately.
|
||||
- After partial overflow, fallback delivery sends the remaining tail and then marks final content delivered only if the fallback send succeeds.
|
||||
- If fallback delivery also fails, the consumer leaves final-delivery false so the gateway's non-streaming final-send safety path can still run.
|
||||
|
||||
**Verification:** Stream consumer tests reproduce the screenshot shape by simulating first chunk visible and continuation failure, then assert the final answer is not suppressed.
|
||||
|
||||
### U3. Preserve Telegram topic/thread routing for overflow and fallback continuations
|
||||
|
||||
**Goal:** Ensure overflow recovery messages land in the same Telegram forum topic or DM topic fallback context.
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Dependencies:** U1, U2
|
||||
|
||||
**Files:**
|
||||
- `gateway/platforms/telegram.py`
|
||||
- `gateway/stream_consumer.py`
|
||||
- `tests/gateway/test_stream_consumer_thread_routing.py`
|
||||
- Relevant Telegram adapter routing tests, if existing coverage is closer there
|
||||
|
||||
**Approach:**
|
||||
- Keep passing `metadata` through every overflow continuation and fallback send.
|
||||
- Keep reply anchors where valid, but do not let a missing reply anchor drop the `message_thread_id` for normal forum topics.
|
||||
- For private DM topic fallback metadata, preserve the existing stricter anchor behaviour documented in the adapter comments.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `TelegramAdapter._thread_kwargs_for_send(...)`.
|
||||
- Existing tests around Telegram topic recovery and stream consumer thread routing.
|
||||
|
||||
**Test scenarios:**
|
||||
- Overflow continuations include `message_thread_id` for a forum topic.
|
||||
- A continuation retry after `reply message not found` keeps forum topic routing when allowed.
|
||||
- Partial-overflow fallback sends receive the same metadata passed to the original stream consumer.
|
||||
|
||||
**Verification:** Thread-routing assertions inspect fake bot calls and confirm all continuation/fallback messages carry the expected topic metadata.
|
||||
|
||||
### U4. Add issue evidence and PR body traceability
|
||||
|
||||
**Goal:** Make the upstream issue and PR clearly trace the user-visible bug and verification evidence.
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** U1, U2, U3
|
||||
|
||||
**Files:**
|
||||
- GitHub issue body created via `gh issue create`
|
||||
- PR body using `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
|
||||
**Approach:**
|
||||
- Create a GitHub issue with the screenshot evidence: the long message in the `Nehemiah - Coding` Telegram topic stops at `- The visible tool-call summary`, and the user's reply says the previous message did not finish streaming to that Telegram topic.
|
||||
- Reference affected component as Gateway and platform as Telegram.
|
||||
- In the PR body, link the issue with `Fixes #...`, describe the split-delivery contract change, and include the screenshot or attach it if GitHub upload is available.
|
||||
- Follow `CONTRIBUTING.md` and the repository PR template exactly.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||
- `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none, this is tracker and PR documentation work.
|
||||
|
||||
**Verification:** The GitHub issue exists with screenshot evidence or an explicit screenshot reference, and the PR body links the issue and lists the tests run.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- Telegram streamed response overflow splitting and recovery.
|
||||
- Stream consumer final-delivery truth for partial overflow delivery.
|
||||
- Topic/thread metadata preservation for overflow and fallback continuation sends.
|
||||
- Focused unit tests around adapter and stream consumer behaviour.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Changing model streaming semantics in `run_agent.py`.
|
||||
- Reworking Telegram draft streaming, which is DM-only and not the forum-topic path in the screenshot.
|
||||
- Changing general platform message splitting for Discord, Slack, WhatsApp, or Matrix unless a shared helper must be corrected for the Telegram fix.
|
||||
- Altering tool-progress display settings or terminal progress rendering.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
|
||||
- Broader observability for gateway delivery completeness across all messaging platforms.
|
||||
- A user-facing resend/recover command for a previous truncated response.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- Risk: fallback recovery duplicates already-visible first chunks. Mitigation: use delivered-prefix metadata where reliable and add tests for no-duplicate complete-success behaviour.
|
||||
- Risk: preserving forum topic routing while dropping invalid reply anchors is easy to regress. Mitigation: include fake bot call assertions for `message_thread_id` and reply behaviour.
|
||||
- Risk: MarkdownV2 formatting can alter visible/raw prefix comparisons. Mitigation: keep fallback conservative; duplicate content is preferable to silently missing content, but tests should keep the common path tail-only.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- User-provided screenshot at `/root/.hermes/image_cache/img_f664e68f6ddf.jpg`.
|
||||
- `gateway/stream_consumer.py` streamed edit, overflow, fallback, and final-delivery state handling.
|
||||
- `gateway/platforms/telegram.py` Telegram send/edit overflow splitting and topic routing helpers.
|
||||
- `gateway/platforms/base.py` `SendResult` contract and shared message chunking helper.
|
||||
- `tests/gateway/test_stream_consumer.py`, `tests/gateway/test_stream_consumer_thread_routing.py`, and Telegram adapter tests for focused regression coverage.
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
- Run focused Telegram adapter overflow tests.
|
||||
- Run focused stream consumer overflow/fallback tests.
|
||||
- Run topic-routing tests affected by metadata changes.
|
||||
- Run the gateway test subset around Telegram send/edit, stream consumer, and run progress if touched.
|
||||
- Before PR creation, ensure `git diff` contains only the plan, implementation, tests, and PR/issue-relevant documentation for this bug.
|
||||
|
|
@ -1222,17 +1222,30 @@ def load_gateway_config() -> GatewayConfig:
|
|||
if isinstance(matrix_cfg, dict):
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
|
||||
allowed_users = matrix_cfg.get("allowed_users")
|
||||
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
|
||||
allowed_rooms = matrix_cfg.get("allowed_rooms")
|
||||
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(allowed_rooms, list):
|
||||
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
|
||||
# allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
|
||||
ar = matrix_cfg.get("allowed_rooms")
|
||||
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(ar, list):
|
||||
ar = ",".join(str(v) for v in ar)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
|
||||
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
|
||||
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
|
||||
if isinstance(ignore_patterns, list):
|
||||
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
|
||||
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
|
||||
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
|
||||
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
|
||||
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
|
||||
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
|
|
@ -1556,8 +1569,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||
matrix_password = os.getenv("MATRIX_PASSWORD", "")
|
||||
if matrix_password:
|
||||
matrix_config.extra["password"] = matrix_password
|
||||
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
|
||||
matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower()
|
||||
matrix_e2ee = (
|
||||
matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred")
|
||||
or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
||||
)
|
||||
matrix_config.extra["encryption"] = matrix_e2ee
|
||||
if matrix_e2ee_mode:
|
||||
matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
|
||||
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
|
||||
if matrix_device_id:
|
||||
matrix_config.extra["device_id"] = matrix_device_id
|
||||
|
|
|
|||
|
|
@ -1545,6 +1545,13 @@ class SendResult:
|
|||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
raw_response: Any = None
|
||||
# Adapter-specific metadata. Cross-layer contracts that affect delivery
|
||||
# semantics must be documented at the producer and consumer sites. Current
|
||||
# known contract: Telegram edit overflow partials set
|
||||
# raw_response["partial_overflow"] with delivered_chunks, total_chunks,
|
||||
# last_message_id, delivered_prefix, and continuation_message_ids so the
|
||||
# stream consumer can send the missing tail instead of marking a clipped
|
||||
# response complete.
|
||||
retryable: bool = False # True for transient connection errors — base will retry automatically
|
||||
# When the adapter had to split an oversized payload across multiple
|
||||
# platform messages (e.g. Telegram edit_message overflow split-and-deliver),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2348,10 +2348,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
except Exception as fmt_err:
|
||||
if "not modified" not in str(fmt_err).lower():
|
||||
logger.warning(
|
||||
"[%s] Overflow split: MarkdownV2 first-chunk edit "
|
||||
"failed, falling back to plain text: %s",
|
||||
self.name, fmt_err,
|
||||
)
|
||||
await self._bot.edit_message_text(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
text=first_chunk,
|
||||
text=_strip_mdv2(first_chunk),
|
||||
)
|
||||
else:
|
||||
await self._bot.edit_message_text(
|
||||
|
|
@ -2379,6 +2384,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
# are already correctly sized). Best-effort MarkdownV2 with plain
|
||||
# fallback, mirroring send().
|
||||
continuation_ids: list[str] = []
|
||||
delivered_chunks = [first_chunk]
|
||||
prev_id = message_id
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
for chunk in chunks[1:]:
|
||||
|
|
@ -2392,7 +2398,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
for use_markdown in (True, False) if finalize else (False,):
|
||||
try:
|
||||
text = self.format_message(chunk) if use_markdown else chunk
|
||||
if use_markdown:
|
||||
text = self.format_message(chunk)
|
||||
else:
|
||||
# Plain attempt: on finalize the MarkdownV2 attempt
|
||||
# failed, so degrade to clean stripped text, never
|
||||
# the raw chunk (raw ** / ``` markers would render
|
||||
# literally); streaming previews stay raw.
|
||||
text = _strip_mdv2(chunk) if finalize else chunk
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
|
|
@ -2418,7 +2431,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
try:
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=chunk,
|
||||
text=_strip_mdv2(chunk) if finalize else chunk,
|
||||
**retry_thread_kwargs,
|
||||
**self._link_preview_kwargs(),
|
||||
**self._notification_kwargs(metadata),
|
||||
|
|
@ -2442,17 +2455,37 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
break
|
||||
if sent_msg is None:
|
||||
# Continuation failed — the user has chunk 1 + however many
|
||||
# continuations succeeded. Report success with what we got
|
||||
# so the stream consumer knows the edit landed; the
|
||||
# remaining tail is lost on this attempt and the next
|
||||
# streaming tick may retry.
|
||||
# continuations succeeded, but NOT the full response. Do not
|
||||
# report success: the stream consumer treats a successful edit
|
||||
# as final delivery on got_done, which would suppress fallback
|
||||
# delivery and leave the Telegram topic clipped after the last
|
||||
# delivered chunk.
|
||||
logger.warning(
|
||||
"[%s] Overflow split: stopped at %d/%d chunks delivered",
|
||||
self.name, 1 + len(continuation_ids), len(chunks),
|
||||
)
|
||||
break
|
||||
delivered_prefix = "".join(
|
||||
re.sub(r" \(\d+/\d+\)$", "", delivered)
|
||||
for delivered in delivered_chunks
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
message_id=prev_id,
|
||||
error="overflow_continuation_failed",
|
||||
retryable=True,
|
||||
raw_response={
|
||||
"partial_overflow": True,
|
||||
"delivered_chunks": 1 + len(continuation_ids),
|
||||
"total_chunks": len(chunks),
|
||||
"last_message_id": prev_id,
|
||||
"delivered_prefix": delivered_prefix,
|
||||
"continuation_message_ids": tuple(continuation_ids),
|
||||
},
|
||||
continuation_message_ids=tuple(continuation_ids),
|
||||
)
|
||||
new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
|
||||
continuation_ids.append(new_id)
|
||||
delivered_chunks.append(chunk)
|
||||
prev_id = new_id
|
||||
|
||||
last_id = continuation_ids[-1] if continuation_ids else message_id
|
||||
|
|
@ -3804,6 +3837,33 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return error
|
||||
|
||||
def _telegram_media_too_large_note(self, label: str, file_size: Any, max_bytes: int) -> str:
|
||||
limit_mb = max(1, max_bytes // (1024 * 1024))
|
||||
try:
|
||||
size_mb = int(file_size or 0) / (1024 * 1024)
|
||||
size_text = f"{size_mb:.1f} MB"
|
||||
except (TypeError, ValueError):
|
||||
size_text = "unknown size"
|
||||
return (
|
||||
f"[Telegram {label} skipped: file size {size_text} exceeds the "
|
||||
f"{limit_mb} MB limit. Ask the user to send a shorter voice note "
|
||||
"or a smaller audio file.]"
|
||||
)
|
||||
|
||||
def _telegram_media_size_allowed(self, source: Any, label: str) -> tuple[bool, Optional[str]]:
|
||||
"""Validate Telegram media size before downloading into memory."""
|
||||
max_bytes = int(getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) or 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if size <= 0:
|
||||
return True, None
|
||||
if size <= max_bytes:
|
||||
return True, None
|
||||
return False, self._telegram_media_too_large_note(label, size, max_bytes)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
@ -5569,6 +5629,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
# Download voice/audio messages to cache for STT transcription
|
||||
if msg.voice:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.voice.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
|
||||
|
|
@ -5579,6 +5645,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
|
||||
elif msg.audio:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.audio.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
|
||||
|
|
|
|||
|
|
@ -190,6 +190,22 @@ from gateway.platforms.base import (
|
|||
)
|
||||
|
||||
|
||||
def _file_content_hash(path: Path) -> str:
|
||||
"""Return the first 16 hex chars of the SHA-256 of *path*'s contents.
|
||||
|
||||
Used for the bridge staleness handshake: bridge.js reports its own
|
||||
source hash in ``/health`` (``scriptHash``), and the adapter compares
|
||||
it against the hash of bridge.js currently on disk. A mismatch means
|
||||
a long-lived bridge process is serving code from before an update.
|
||||
Returns ``""`` when the file can't be read.
|
||||
"""
|
||||
import hashlib
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def check_whatsapp_requirements() -> bool:
|
||||
"""
|
||||
Check if WhatsApp dependencies are available.
|
||||
|
|
@ -372,9 +388,21 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
|
||||
|
||||
try:
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
# Auto-install npm dependencies when node_modules is missing OR
|
||||
# package.json changed since the last install (e.g. after
|
||||
# `hermes update` bumps the Baileys pin). The stamp file records
|
||||
# the package.json hash of the last successful install.
|
||||
bridge_dir = bridge_path.parent
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
_pkg_json = bridge_dir / "package.json"
|
||||
_dep_stamp = bridge_dir / "node_modules" / ".hermes-pkg-hash"
|
||||
_pkg_hash = _file_content_hash(_pkg_json)
|
||||
_deps_fresh = False
|
||||
if (bridge_dir / "node_modules").exists():
|
||||
try:
|
||||
_deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
|
||||
except OSError:
|
||||
_deps_fresh = False
|
||||
if not _deps_fresh:
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
# Resolve npm path so Windows can execute the .cmd shim.
|
||||
# shutil.which honours PATHEXT; on POSIX it returns the
|
||||
|
|
@ -395,6 +423,11 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
print(f"[{self.name}] npm install failed: {install_result.stderr}")
|
||||
return False
|
||||
print(f"[{self.name}] Dependencies installed")
|
||||
if _pkg_hash:
|
||||
try:
|
||||
_dep_stamp.write_text(_pkg_hash)
|
||||
except OSError:
|
||||
pass # Stamp is an optimization; install still succeeded
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to install dependencies: {e}")
|
||||
return False
|
||||
|
|
@ -414,12 +447,28 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
data = await resp.json()
|
||||
bridge_status = data.get("status", "unknown")
|
||||
if bridge_status == "connected":
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
# Staleness handshake: only reuse a running
|
||||
# bridge if it is serving the same bridge.js
|
||||
# that is on disk right now. A long-lived
|
||||
# bridge survives gateway restarts AND
|
||||
# `hermes update`, so without this check it
|
||||
# keeps serving pre-update code forever
|
||||
# (e.g. no inbound media download). Old
|
||||
# bridges that don't report scriptHash are
|
||||
# treated as stale by definition.
|
||||
running_hash = data.get("scriptHash", "")
|
||||
disk_hash = _file_content_hash(bridge_path)
|
||||
if running_hash and disk_hash and running_hash == disk_hash:
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
print(
|
||||
f"[{self.name}] Running bridge is stale "
|
||||
f"(running={running_hash or 'unversioned'}, disk={disk_hash}), restarting"
|
||||
)
|
||||
else:
|
||||
print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
|
||||
except Exception:
|
||||
|
|
@ -444,6 +493,18 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
bridge_env = os.environ.copy()
|
||||
if self._reply_prefix is not None:
|
||||
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
|
||||
# Pass the profile-aware cache directories so the bridge writes
|
||||
# media where the Python side reads it. Without these the bridge
|
||||
# hardcodes ~/.hermes/{image,audio,document}_cache, which diverges
|
||||
# under HERMES_HOME overrides, profiles, and the new cache/ layout.
|
||||
from gateway.platforms.base import (
|
||||
get_audio_cache_dir as _get_audio_dir,
|
||||
get_document_cache_dir as _get_doc_dir,
|
||||
get_image_cache_dir as _get_img_dir,
|
||||
)
|
||||
bridge_env["HERMES_IMAGE_CACHE_DIR"] = str(_get_img_dir())
|
||||
bridge_env["HERMES_AUDIO_CACHE_DIR"] = str(_get_audio_dir())
|
||||
bridge_env["HERMES_DOCUMENT_CACHE_DIR"] = str(_get_doc_dir())
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
|
|
|
|||
100
gateway/run.py
100
gateway/run.py
|
|
@ -32,6 +32,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import shlex
|
||||
import site
|
||||
import sys
|
||||
import signal
|
||||
import tempfile
|
||||
|
|
@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = (
|
|||
)
|
||||
|
||||
|
||||
def _ensure_windows_gateway_venv_imports() -> None:
|
||||
"""Make detached Windows gateway runs see the Hermes venv packages.
|
||||
|
||||
Some Windows restart paths run the gateway under uv's base ``pythonw.exe``
|
||||
to avoid the venv launcher respawning a visible console interpreter. That
|
||||
mode can import the source tree via cwd/PYTHONPATH but still miss optional
|
||||
packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK).
|
||||
Patch the live process before MCP discovery so tool injection does not
|
||||
depend on every launcher preserving PYTHONPATH perfectly.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
candidates: list[Path] = []
|
||||
if os.environ.get("VIRTUAL_ENV"):
|
||||
candidates.append(Path(os.environ["VIRTUAL_ENV"]))
|
||||
candidates.append(project_root / "venv")
|
||||
|
||||
seen: set[str] = set()
|
||||
for venv_dir in candidates:
|
||||
try:
|
||||
resolved_venv = venv_dir.resolve()
|
||||
except OSError:
|
||||
resolved_venv = venv_dir
|
||||
venv_key = str(resolved_venv).lower()
|
||||
if venv_key in seen:
|
||||
continue
|
||||
seen.add(venv_key)
|
||||
|
||||
site_packages = resolved_venv / "Lib" / "site-packages"
|
||||
if not site_packages.exists():
|
||||
continue
|
||||
|
||||
project_entry = str(project_root)
|
||||
site_entry = str(site_packages)
|
||||
if project_entry not in sys.path:
|
||||
sys.path.insert(0, project_entry)
|
||||
# addsitepackages() semantics matter here: pywin32, used by the MCP
|
||||
# SDK on Windows, relies on .pth processing to expose pywintypes.
|
||||
site.addsitedir(site_entry)
|
||||
if site_entry in sys.path:
|
||||
sys.path.remove(site_entry)
|
||||
insert_at = 1 if sys.path and sys.path[0] == project_entry else 0
|
||||
sys.path.insert(insert_at, site_entry)
|
||||
|
||||
os.environ["VIRTUAL_ENV"] = str(resolved_venv)
|
||||
pythonpath = [project_entry, site_entry]
|
||||
if os.environ.get("PYTHONPATH"):
|
||||
pythonpath.append(os.environ["PYTHONPATH"])
|
||||
os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
return
|
||||
|
||||
|
||||
def _gateway_platform_value(platform: Any) -> str:
|
||||
"""Return a normalized gateway platform value for enums or raw strings."""
|
||||
return str(getattr(platform, "value", platform) or "").strip().lower()
|
||||
|
|
@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
)
|
||||
"""
|
||||
).strip()
|
||||
watcher_env = os.environ.copy()
|
||||
# This watcher is intentionally outside the running gateway. If it
|
||||
# inherits the gateway marker, `hermes gateway restart` refuses to
|
||||
# run as a self-restart loop guard and the gateway stays stopped.
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
if site_packages.exists():
|
||||
watcher_env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
pythonpath = [str(project_root), str(site_packages)]
|
||||
if watcher_env.get("PYTHONPATH"):
|
||||
pythonpath.append(watcher_env["PYTHONPATH"])
|
||||
watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", watcher, str(current_pid), *cmd_argv],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
return
|
||||
|
|
@ -4268,12 +4338,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; "
|
||||
f"{cmd} gateway restart"
|
||||
)
|
||||
# Same marker scrub as the Windows watcher above: this watcher runs
|
||||
# `hermes gateway restart` from outside the gateway, but it inherits
|
||||
# _HERMES_GATEWAY=1 from us, and the CLI's self-restart loop guard
|
||||
# refuses to run when that marker is set — silently (DEVNULL), so the
|
||||
# gateway stops and never comes back.
|
||||
watcher_env = os.environ.copy()
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
setsid_bin = shutil.which("setsid")
|
||||
if setsid_bin:
|
||||
subprocess.Popen(
|
||||
[setsid_bin, "bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
|
|
@ -4281,6 +4359,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
["bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
|
@ -12946,6 +13025,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
# True when the previously enqueued progress line was a terminal
|
||||
# fenced code block — consecutive terminal calls then drop the
|
||||
# repeated "💻 terminal" header and render back-to-back blocks.
|
||||
last_was_terminal_block = [False]
|
||||
|
||||
# ── Discord voice "verbal ack before tool calls" ────────────────
|
||||
# When the bot is in a voice channel with the continuous mixer
|
||||
|
|
@ -13102,7 +13185,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
):
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_cmd_full = args["command"].rstrip()
|
||||
_code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```"
|
||||
# Consecutive terminal calls: drop the repeated
|
||||
# "💻 terminal" header so back-to-back commands render as
|
||||
# adjacent code blocks under a single header.
|
||||
_block_header = (
|
||||
"" if last_was_terminal_block[0] else f"{emoji} {tool_name}\n"
|
||||
)
|
||||
_code_block_full = f"{_block_header}```\n{_cmd_full}\n```"
|
||||
# Single-line, capped preview for non-verbose modes.
|
||||
_pl = get_tool_preview_max_len()
|
||||
_cap = _pl if _pl > 0 else 40
|
||||
|
|
@ -13113,13 +13202,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_cmd_short = _cmd_short[:_cap - 3] + "..."
|
||||
elif _multiline:
|
||||
_cmd_short = _cmd_short + " ..."
|
||||
_code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```"
|
||||
_code_block_short = f"{_block_header}```\n{_cmd_short}\n```"
|
||||
|
||||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||||
if progress_mode == "verbose":
|
||||
if _code_block_full is not None:
|
||||
last_was_terminal_block[0] = True
|
||||
progress_queue.put(_code_block_full)
|
||||
return
|
||||
last_was_terminal_block[0] = False
|
||||
if args:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
|
|
@ -13144,6 +13235,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
# fenced block (built above) instead of the truncated preview.
|
||||
if _code_block_short is not None:
|
||||
msg = _code_block_short
|
||||
last_was_terminal_block[0] = True
|
||||
elif preview:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
|
|
@ -13151,8 +13243,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if len(preview) > _cap:
|
||||
preview = preview[:_cap - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
last_was_terminal_block[0] = False
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
last_was_terminal_block[0] = False
|
||||
|
||||
# Dedup: collapse consecutive identical progress messages.
|
||||
# Common with execute_code where models iterate with the same
|
||||
|
|
@ -15909,6 +16003,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
|||
atexit.register(remove_pid_file)
|
||||
atexit.register(release_gateway_runtime_lock)
|
||||
|
||||
_ensure_windows_gateway_venv_imports()
|
||||
|
||||
# MCP tool discovery — run in an executor so the asyncio event loop
|
||||
# stays responsive even when a configured MCP server is slow or
|
||||
# unreachable. discover_mcp_tools() uses a blocking 120s wait
|
||||
|
|
|
|||
|
|
@ -294,6 +294,22 @@ def build_session_context_prompt(
|
|||
if context.source.chat_topic:
|
||||
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
|
||||
|
||||
if context.source.platform == Platform.MATRIX:
|
||||
src = context.source
|
||||
room_name = src.chat_name or src.chat_id
|
||||
room_id = _hash_chat_id(src.chat_id) if redact_pii else src.chat_id
|
||||
lines.append("")
|
||||
lines.append(f"**Matrix Room:** {room_name}")
|
||||
lines.append(f"**Matrix Room ID:** {room_id}")
|
||||
if src.thread_id:
|
||||
thread_id = _hash_chat_id(src.thread_id) if redact_pii else src.thread_id
|
||||
lines.append(f"**Matrix Thread:** {thread_id}")
|
||||
lines.append(
|
||||
"**Matrix room boundary:** Treat this turn as scoped to the current "
|
||||
"Matrix room/thread only. Do not assume unresolved references are "
|
||||
"about other Matrix rooms or projects unless the user explicitly says so."
|
||||
)
|
||||
|
||||
# User identity.
|
||||
# In shared multi-user sessions (shared threads OR shared non-thread groups
|
||||
# when group_sessions_per_user=False), multiple users contribute to the same
|
||||
|
|
@ -1264,6 +1280,17 @@ class SessionStore:
|
|||
entries.sort(key=lambda e: e.updated_at, reverse=True)
|
||||
|
||||
return entries
|
||||
|
||||
def lookup_by_session_id(self, session_id: str) -> Optional[SessionEntry]:
|
||||
"""Return the active session entry for a persisted session ID, if any."""
|
||||
if not session_id:
|
||||
return None
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
for entry in self._entries.values():
|
||||
if entry.session_id == session_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
|
||||
"""Append a message to a session's transcript (SQLite).
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -32,7 +33,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
|||
from agent.i18n import t
|
||||
from gateway.config import HomeChannel, Platform, PlatformConfig
|
||||
from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType
|
||||
from gateway.session import build_session_key
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_cli.config import cfg_get
|
||||
from utils import (
|
||||
atomic_json_write,
|
||||
|
|
@ -447,6 +448,22 @@ class GatewaySlashCommandsMixin:
|
|||
])
|
||||
if queue_depth:
|
||||
lines.append(t("gateway.status.queued", count=queue_depth))
|
||||
if source.platform == Platform.MATRIX:
|
||||
adapter = self.adapters.get(Platform.MATRIX)
|
||||
scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto"))
|
||||
thread = source.thread_id or "none"
|
||||
lines.extend([
|
||||
"",
|
||||
t("gateway.status.matrix_scope_header"),
|
||||
t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
|
||||
t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
|
||||
t("gateway.status.matrix_scope_thread", thread_id=thread),
|
||||
t("gateway.status.matrix_scope_mode", scope=scope),
|
||||
t(
|
||||
"gateway.status.matrix_scope_key",
|
||||
session_key=self._redact_matrix_session_key(session_key),
|
||||
),
|
||||
])
|
||||
lines.extend([
|
||||
"",
|
||||
t("gateway.status.platforms", platforms=', '.join(connected_platforms)),
|
||||
|
|
@ -454,6 +471,37 @@ class GatewaySlashCommandsMixin:
|
|||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _redact_matrix_session_key(session_key: str) -> str:
|
||||
"""Return a stable Matrix session-key fingerprint for shared room status."""
|
||||
text = str(session_key or "")
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
||||
return f"sha256:{digest}"
|
||||
|
||||
def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]:
|
||||
"""Best-effort origin lookup for gateway session IDs."""
|
||||
lookup = getattr(type(self.session_store), "lookup_by_session_id", None)
|
||||
if callable(lookup):
|
||||
entry = lookup(self.session_store, session_id)
|
||||
return getattr(entry, "origin", None) if entry is not None else None
|
||||
|
||||
# Test doubles and older stores may not expose the public lookup helper.
|
||||
# Keep the Matrix resume guard fail-closed if no origin can be resolved.
|
||||
entries = getattr(self.session_store, "_entries", {}) or {}
|
||||
for entry in entries.values():
|
||||
if getattr(entry, "session_id", None) == session_id:
|
||||
return getattr(entry, "origin", None)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool:
|
||||
return (
|
||||
origin is not None
|
||||
and origin.platform == Platform.MATRIX
|
||||
and current.platform == Platform.MATRIX
|
||||
and origin.chat_id == current.chat_id
|
||||
)
|
||||
|
||||
async def _handle_agents_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /agents command - list active agents and running tasks."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
|
|
@ -2652,7 +2700,14 @@ class GatewaySlashCommandsMixin:
|
|||
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
name = event.get_command_args().strip()
|
||||
raw_args = event.get_command_args().strip()
|
||||
try:
|
||||
parts = shlex.split(raw_args)
|
||||
except ValueError as exc:
|
||||
return t("gateway.resume.parse_error", error=exc)
|
||||
allow_all = "--all" in parts
|
||||
allow_cross_room = "--cross-room" in parts
|
||||
name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip()
|
||||
|
||||
# Strip common outer brackets/quotes users may type literally from the
|
||||
# usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior.
|
||||
|
|
@ -2673,11 +2728,24 @@ class GatewaySlashCommandsMixin:
|
|||
# List recent titled sessions for this user/platform
|
||||
try:
|
||||
titled = _list_titled_sessions()
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
scoped = []
|
||||
for s in titled:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if self._same_matrix_room(source, origin):
|
||||
scoped.append(s)
|
||||
titled = scoped
|
||||
if not titled:
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
return t("gateway.resume.matrix_no_named_sessions")
|
||||
return t("gateway.resume.no_named_sessions")
|
||||
lines = [t("gateway.resume.list_header")]
|
||||
for idx, s in enumerate(titled[:10], start=1):
|
||||
title = s["title"]
|
||||
if source.platform == Platform.MATRIX and allow_all:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if origin:
|
||||
title = f"{title} — {origin.chat_name or origin.chat_id}"
|
||||
preview = s.get("preview", "")[:40]
|
||||
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
|
||||
lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
|
||||
|
|
@ -2691,6 +2759,13 @@ class GatewaySlashCommandsMixin:
|
|||
if name.isdigit():
|
||||
try:
|
||||
titled = _list_titled_sessions()
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
scoped = []
|
||||
for s in titled:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if self._same_matrix_room(source, origin):
|
||||
scoped.append(s)
|
||||
titled = scoped
|
||||
except Exception as e:
|
||||
logger.debug("Failed to list titled sessions for numeric resume: %s", e)
|
||||
return t("gateway.resume.list_failed", error=e)
|
||||
|
|
@ -2717,6 +2792,17 @@ class GatewaySlashCommandsMixin:
|
|||
except Exception as e:
|
||||
logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e)
|
||||
|
||||
if source.platform == Platform.MATRIX:
|
||||
target_origin = self._gateway_session_origin_for_id(target_id)
|
||||
if not self._same_matrix_room(source, target_origin) and not allow_cross_room:
|
||||
if target_origin is None:
|
||||
return t("gateway.resume.matrix_blocked_no_origin", name=name)
|
||||
return t(
|
||||
"gateway.resume.matrix_blocked_other_room",
|
||||
room=target_origin.chat_name or target_origin.chat_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
# Check if already on that session
|
||||
current_entry = self.session_store.get_or_create_session(source)
|
||||
if current_entry.session_id == target_id:
|
||||
|
|
@ -2744,6 +2830,15 @@ class GatewaySlashCommandsMixin:
|
|||
# Count messages for context
|
||||
history = self.session_store.load_transcript(target_id)
|
||||
msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0
|
||||
msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else ""
|
||||
|
||||
if source.platform == Platform.MATRIX and allow_cross_room:
|
||||
return t(
|
||||
"gateway.resume.matrix_cross_room_success",
|
||||
title=title,
|
||||
room=source.chat_name or source.chat_id,
|
||||
msg_part=msg_part,
|
||||
)
|
||||
if not msg_count:
|
||||
return t("gateway.resume.resumed_no_count", title=title)
|
||||
if msg_count == 1:
|
||||
|
|
|
|||
|
|
@ -147,8 +147,15 @@ class GatewayStreamConsumer:
|
|||
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
||||
self._last_edit_time = 0.0
|
||||
self._last_sent_text = "" # Track last-sent text to skip redundant edits
|
||||
# True when the most recent _send_or_edit split-and-delivered across
|
||||
# continuation messages (the adapter adopted a new message id).
|
||||
self._last_edit_overflowed = False
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
# True when fallback is sending only the missing tail after a partial
|
||||
# Telegram overflow delivery. In that case the already-visible prefix
|
||||
# is intentional content, not a stale preview to delete.
|
||||
self._fallback_preserve_partial_messages = False
|
||||
self._flood_strikes = 0 # Consecutive flood-control edit failures
|
||||
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
||||
self._final_response_sent = False
|
||||
|
|
@ -261,6 +268,7 @@ class GatewayStreamConsumer:
|
|||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
self._fallback_preserve_partial_messages = False
|
||||
# #29346: a tool/segment boundary means what we delivered was an interim
|
||||
# preamble, not the final answer — clear the flags so a premature setter
|
||||
# can't fool the gateway. Safe: got_done returns before any reset, and
|
||||
|
|
@ -581,14 +589,20 @@ class GatewayStreamConsumer:
|
|||
if self._accumulated:
|
||||
if self._fallback_final_send:
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif (
|
||||
current_update_visible
|
||||
and not self._adapter_requires_finalize
|
||||
elif current_update_visible and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
):
|
||||
# Mid-stream edit above already delivered the
|
||||
# final accumulated content. Skip the redundant
|
||||
# final edit — but only for adapters that don't
|
||||
# need an explicit finalize signal.
|
||||
# final edit for adapters that don't need an
|
||||
# explicit finalize signal, and for any adapter
|
||||
# when that edit split-and-delivered across
|
||||
# continuations: the split edit carried
|
||||
# finalize=True itself, and re-finalizing with
|
||||
# the full text would overflow-split again into
|
||||
# the adopted continuation, duplicating chunks
|
||||
# on screen.
|
||||
self._final_response_sent = True
|
||||
self._final_content_delivered = True
|
||||
elif self._message_id:
|
||||
|
|
@ -647,11 +661,21 @@ class GatewayStreamConsumer:
|
|||
await asyncio.sleep(0.05) # Small yield to not busy-loop
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Best-effort final edit on cancellation
|
||||
# Best-effort final edit on cancellation. finalize=True so
|
||||
# REQUIRES_EDIT_FINALIZE platforms (Telegram) apply final
|
||||
# formatting — a plain edit here would leave the entire reply
|
||||
# rendered as a raw streaming preview while the success flags
|
||||
# below suppress the gateway's formatted re-send.
|
||||
# is_turn_final=False keeps _try_fresh_final from setting
|
||||
# _final_response_sent itself; this handler owns the flags.
|
||||
_best_effort_ok = False
|
||||
if self._accumulated and self._message_id:
|
||||
try:
|
||||
_best_effort_ok = bool(await self._send_or_edit(self._accumulated))
|
||||
_best_effort_ok = bool(
|
||||
await self._send_or_edit(
|
||||
self._accumulated, finalize=True, is_turn_final=False,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Only confirm final delivery if the best-effort send above
|
||||
|
|
@ -867,11 +891,21 @@ class GatewayStreamConsumer:
|
|||
self._notify_new_message()
|
||||
|
||||
# Remove the frozen partial message so the user only sees the
|
||||
# complete fallback response. Best-effort — if the platform doesn't
|
||||
# complete fallback response. ONLY safe when the fallback re-sent
|
||||
# the FULL final text (continuation == final_text). When the
|
||||
# prefix-based dedup above sent only the missing TAIL, the partial
|
||||
# message IS the head of the answer — deleting it leaves the user
|
||||
# with only the last part of the response (the "Gemini sent only
|
||||
# the second half" symptom). Best-effort — if the platform doesn't
|
||||
# implement ``delete_message``, the delete fails (flood control still
|
||||
# active, bot lacks permission, message too old to delete), the
|
||||
# partial remains but at least the full answer was delivered.
|
||||
if stale_message_id and stale_message_id != last_message_id:
|
||||
if (
|
||||
stale_message_id
|
||||
and stale_message_id != last_message_id
|
||||
and not self._fallback_preserve_partial_messages
|
||||
and continuation == final_text
|
||||
):
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
try:
|
||||
|
|
@ -888,6 +922,7 @@ class GatewayStreamConsumer:
|
|||
self._final_content_delivered = True
|
||||
self._last_sent_text = chunks[-1]
|
||||
self._fallback_prefix = ""
|
||||
self._fallback_preserve_partial_messages = False
|
||||
|
||||
def _is_flood_error(self, result) -> bool:
|
||||
"""Check if a SendResult failure is due to flood control / rate limiting."""
|
||||
|
|
@ -1208,6 +1243,7 @@ class GatewayStreamConsumer:
|
|||
return True
|
||||
# Failure already disabled drafts for this run; fall through to
|
||||
# the regular edit/send path below.
|
||||
self._last_edit_overflowed = False
|
||||
try:
|
||||
if self._message_id is not None:
|
||||
if self._edit_supported:
|
||||
|
|
@ -1264,6 +1300,7 @@ class GatewayStreamConsumer:
|
|||
and result.message_id
|
||||
and result.message_id != self._message_id
|
||||
):
|
||||
self._last_edit_overflowed = True
|
||||
self._message_id = str(result.message_id)
|
||||
self._message_created_ts = time.monotonic()
|
||||
self._last_sent_text = ""
|
||||
|
|
@ -1274,6 +1311,35 @@ class GatewayStreamConsumer:
|
|||
self._flood_strikes = 0
|
||||
return True
|
||||
else:
|
||||
raw_response = getattr(result, "raw_response", None)
|
||||
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
|
||||
# Telegram edited/sent one or more overflow chunks,
|
||||
# but not the complete response. Preserve the
|
||||
# visible prefix so the got_done fallback sends the
|
||||
# missing tail instead of marking a clipped topic
|
||||
# reply as final delivery.
|
||||
self._message_id = str(
|
||||
raw_response.get("last_message_id")
|
||||
or result.message_id
|
||||
or self._message_id
|
||||
)
|
||||
delivered_prefix = raw_response.get("delivered_prefix")
|
||||
if isinstance(delivered_prefix, str) and delivered_prefix:
|
||||
self._last_sent_text = delivered_prefix
|
||||
self._fallback_prefix = delivered_prefix
|
||||
self._fallback_preserve_partial_messages = text.startswith(
|
||||
delivered_prefix
|
||||
)
|
||||
else:
|
||||
self._fallback_prefix = self._visible_prefix()
|
||||
self._fallback_preserve_partial_messages = False
|
||||
self._fallback_final_send = True
|
||||
self._edit_supported = False
|
||||
self._already_sent = True
|
||||
if getattr(result, "continuation_message_ids", ()):
|
||||
self._notify_new_message()
|
||||
return False
|
||||
|
||||
# Edit failed. If this looks like flood control / rate
|
||||
# limiting, use adaptive backoff: double the edit interval
|
||||
# and retry on the next cycle. Only permanently disable
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ logger = logging.getLogger(__name__)
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory names to skip entirely (matched against each path component)
|
||||
# ``hermes-agent`` is special-cased to root level only in ``_should_exclude``
|
||||
# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/``
|
||||
# are not accidentally excluded.
|
||||
_EXCLUDED_DIRS = {
|
||||
"hermes-agent", # the codebase repo — re-clone instead
|
||||
"__pycache__", # bytecode caches — regenerated on import
|
||||
|
|
@ -69,10 +72,15 @@ def _should_exclude(rel_path: Path) -> bool:
|
|||
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
|
||||
parts = rel_path.parts
|
||||
|
||||
# Any path component matches an excluded dir name
|
||||
for part in parts:
|
||||
if part in _EXCLUDED_DIRS:
|
||||
return True
|
||||
if part not in _EXCLUDED_DIRS:
|
||||
continue
|
||||
# ``hermes-agent`` only matches at the root level (first component).
|
||||
# Nested directories with the same name — e.g.
|
||||
# ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved.
|
||||
if part == "hermes-agent" and part != parts[0]:
|
||||
continue
|
||||
return True
|
||||
|
||||
name = rel_path.name
|
||||
|
||||
|
|
@ -177,10 +185,13 @@ def run_backup(args) -> None:
|
|||
rel_dir = dp.relative_to(hermes_root)
|
||||
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
# ``hermes-agent`` is only pruned at the root level; nested dirs
|
||||
# with the same name (e.g. in skills/) must be preserved.
|
||||
is_root = rel_dir == Path(".")
|
||||
orig_dirnames = dirnames[:]
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in _EXCLUDED_DIRS
|
||||
if d not in _EXCLUDED_DIRS or (d == "hermes-agent" and not is_root)
|
||||
]
|
||||
for removed in set(orig_dirnames) - set(dirnames):
|
||||
skipped_dirs.add(str(rel_dir / removed))
|
||||
|
|
@ -211,7 +222,13 @@ def run_backup(args) -> None:
|
|||
try:
|
||||
# Safe copy for SQLite databases (handles WAL mode)
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
# Stage the snapshot alongside the output zip so that the
|
||||
# temp file lives on the same filesystem. The system
|
||||
# default (/tmp) may be a small tmpfs that cannot hold
|
||||
# large databases, causing silent backup incompleteness.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".db", delete=False, dir=str(out_path.parent)
|
||||
) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
zf.write(tmp_db, arcname=str(rel_path))
|
||||
|
|
@ -853,7 +870,13 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
|
|||
for abs_path, rel_path in files_to_add:
|
||||
try:
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
# Stage the snapshot alongside the output zip so that the
|
||||
# temp file lives on the same filesystem. The system
|
||||
# default (/tmp) may be a small tmpfs that cannot hold
|
||||
# large databases, causing silent backup incompleteness.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".db", delete=False, dir=str(out_path.parent)
|
||||
) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
try:
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import subprocess
|
|||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
|
|
@ -121,6 +122,53 @@ _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
|
|||
UPDATE_AVAILABLE_NO_COUNT = -1
|
||||
|
||||
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
||||
_OFFICIAL_REPO_CANONICAL = "github.com/nousresearch/hermes-agent"
|
||||
|
||||
|
||||
def _canonical_github_remote(url: str | None) -> str:
|
||||
"""Return ``host/owner/repo`` for common GitHub remote URL forms."""
|
||||
if not url:
|
||||
return ""
|
||||
value = url.strip()
|
||||
if value.startswith("git@github.com:"):
|
||||
value = "github.com/" + value[len("git@github.com:"):]
|
||||
elif value.startswith("ssh://git@github.com/"):
|
||||
value = "github.com/" + value[len("ssh://git@github.com/"):]
|
||||
else:
|
||||
parsed = urlparse(value)
|
||||
if parsed.netloc and parsed.path:
|
||||
value = f"{parsed.netloc}{parsed.path}"
|
||||
value = value.strip().rstrip("/")
|
||||
if value.endswith(".git"):
|
||||
value = value[:-4]
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _is_ssh_remote(url: str | None) -> bool:
|
||||
if not url:
|
||||
return False
|
||||
value = url.strip().lower()
|
||||
return value.startswith("git@") or value.startswith("ssh://")
|
||||
|
||||
|
||||
def _is_official_ssh_remote(url: str | None) -> bool:
|
||||
return _is_ssh_remote(url) and _canonical_github_remote(url) == _OFFICIAL_REPO_CANONICAL
|
||||
|
||||
|
||||
def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5) -> Optional[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(cwd),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
|
||||
def _check_via_rev(local_rev: str) -> Optional[int]:
|
||||
|
|
@ -146,6 +194,11 @@ def _check_via_rev(local_rev: str) -> Optional[int]:
|
|||
|
||||
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
|
||||
"""Count commits behind origin/main in a local checkout."""
|
||||
origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir)
|
||||
if _is_official_ssh_remote(origin_url):
|
||||
head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||
return _check_via_rev(head_rev) if head_rev else None
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", "--quiet"],
|
||||
|
|
|
|||
|
|
@ -1544,12 +1544,140 @@ class SlashCommandCompleter(Completer):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _tools_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
|
||||
|
||||
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
|
||||
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
|
||||
keys and MCP server prefixes, filtered by current enable state so the
|
||||
user only sees actionable options).
|
||||
"""
|
||||
SUBS = ("list", "disable", "enable")
|
||||
parts = sub_text.split()
|
||||
trailing_space = sub_text.endswith(" ")
|
||||
|
||||
# Subcommand stage: zero words typed, or completing the first word.
|
||||
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
|
||||
partial = sub_text if not trailing_space else ""
|
||||
for sub in SUBS:
|
||||
if sub.startswith(partial.lower()) and sub != partial.lower():
|
||||
yield Completion(sub, start_position=-len(partial), display=sub)
|
||||
return
|
||||
|
||||
subcommand = parts[0].lower()
|
||||
if subcommand not in ("enable", "disable"):
|
||||
return
|
||||
|
||||
partial = "" if trailing_space else parts[-1]
|
||||
partial_lower = partial.lower()
|
||||
already = set(parts[1:] if trailing_space else parts[1:-1])
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import (
|
||||
CONFIGURABLE_TOOLSETS,
|
||||
_get_platform_tools,
|
||||
_get_plugin_toolset_keys,
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||
|
||||
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta=label,
|
||||
)
|
||||
|
||||
for ts_key in sorted(_get_plugin_toolset_keys()):
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta="plugin toolset",
|
||||
)
|
||||
|
||||
mcp_servers = config.get("mcp_servers") or {}
|
||||
if isinstance(mcp_servers, dict):
|
||||
for server in sorted(mcp_servers):
|
||||
prefix = f"{server}:"
|
||||
if prefix in already or not prefix.startswith(partial_lower):
|
||||
continue
|
||||
yield Completion(
|
||||
prefix,
|
||||
start_position=-len(partial),
|
||||
display=prefix,
|
||||
display_meta=f"MCP server '{server}'",
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _handoff_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield platform completions for /handoff.
|
||||
|
||||
Offers connected (enabled + configured) gateway platforms. A recorded
|
||||
home channel is NOT required to list a platform — it's often learned at
|
||||
runtime — so the meta hints whether one is set yet. Completes only the
|
||||
first arg (the platform); once one is chosen, stop.
|
||||
"""
|
||||
parts = sub_text.split()
|
||||
trailing_space = sub_text.endswith(" ")
|
||||
if len(parts) > 1 or (len(parts) == 1 and trailing_space):
|
||||
return
|
||||
partial = "" if (not parts or trailing_space) else parts[-1]
|
||||
partial_lower = partial.lower()
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
gw = load_gateway_config()
|
||||
platforms = gw.get_connected_platforms()
|
||||
except Exception:
|
||||
return
|
||||
for platform in platforms:
|
||||
name = platform.value
|
||||
if not name.startswith(partial_lower):
|
||||
continue
|
||||
try:
|
||||
home = gw.get_home_channel(platform)
|
||||
except Exception:
|
||||
home = None
|
||||
meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here"
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(partial),
|
||||
display=name,
|
||||
display_meta=meta,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _personality_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /personality from configured personalities."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
personalities = load_config().get("agent", {}).get("personalities", {})
|
||||
# Resolve from the same source the runtime applies personalities —
|
||||
# agent.personalities via the CLI config (which ships the built-ins).
|
||||
# load_config()'s schema has no agent.personalities, so the completer
|
||||
# used to come back empty even with personalities available.
|
||||
from cli import load_cli_config
|
||||
|
||||
personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
|
||||
if "none".startswith(sub_lower) and "none" != sub_lower:
|
||||
yield Completion(
|
||||
"none",
|
||||
|
|
@ -1602,6 +1730,17 @@ class SlashCommandCompleter(Completer):
|
|||
yield from self._personality_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# /tools needs multi-word completion (subcommand + toolset name)
|
||||
# so it handles both stages itself, bypassing the single-word
|
||||
# SUBCOMMANDS branch below.
|
||||
if base_cmd == "/tools":
|
||||
yield from self._tools_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
if base_cmd == "/handoff":
|
||||
yield from self._handoff_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
|
|
|
|||
|
|
@ -270,6 +270,11 @@ _EXTRA_ENV_KEYS = frozenset({
|
|||
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
|
||||
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
# Deprecated tool-progress env vars — replaced by display.tool_progress in
|
||||
# config.yaml. Kept known here so .env sanitization/reload still handle
|
||||
# them for existing users (gateway reads them as a back-compat fallback),
|
||||
# without surfacing them in user-facing OPTIONAL_ENV_VARS listings.
|
||||
"HERMES_TOOL_PROGRESS", "HERMES_TOOL_PROGRESS_MODE",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||||
|
|
@ -863,6 +868,19 @@ DEFAULT_CONFIG = {
|
|||
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
|
||||
# env var overrides this (build-time/container mechanism).
|
||||
"environment_hint": "",
|
||||
# Coding posture — on interactive coding surfaces (CLI, TUI, desktop
|
||||
# app, ACP) in a code workspace, Hermes adds a coding operating brief
|
||||
# + a live git/workspace snapshot to the system prompt. See
|
||||
# agent/coding_context.py.
|
||||
# "auto" (default) — prompt-only posture when the surface is
|
||||
# interactive AND cwd is a code workspace.
|
||||
# Toolsets are never touched; messaging platforms
|
||||
# unaffected.
|
||||
# "focus" — auto + collapse the toolset to the lean coding
|
||||
# set (+ enabled MCP servers). Explicit opt-in.
|
||||
# "on" — force the prompt posture everywhere.
|
||||
# "off" — disable entirely.
|
||||
"coding_context": "auto",
|
||||
# Staged inactivity warning: send a warning to the user at this
|
||||
# threshold before escalating to a full timeout. The warning fires
|
||||
# once per run and does not interrupt the agent. 0 = disable warning.
|
||||
|
|
@ -3544,21 +3562,11 @@ OPTIONAL_ENV_VARS = {
|
|||
},
|
||||
# HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated —
|
||||
# now configured via display.tool_progress in config.yaml (off|new|all|verbose).
|
||||
# Gateway falls back to these env vars for backward compatibility.
|
||||
"HERMES_TOOL_PROGRESS": {
|
||||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||||
"prompt": "Tool progress (deprecated — use config.yaml)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "setting",
|
||||
},
|
||||
"HERMES_TOOL_PROGRESS_MODE": {
|
||||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||||
"prompt": "Progress mode (deprecated — use config.yaml)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "setting",
|
||||
},
|
||||
# The gateway still falls back to these env vars for backward compatibility,
|
||||
# so they live in _EXTRA_ENV_KEYS (known to .env sanitization/reload) but
|
||||
# are intentionally NOT listed here: OPTIONAL_ENV_VARS feeds user-facing
|
||||
# surfaces (dashboard keys page, setup checklists) and deprecated knobs
|
||||
# shouldn't be offered there.
|
||||
"HERMES_PREFILL_MESSAGES_FILE": {
|
||||
"description": "Path to JSON file with ephemeral prefill messages for few-shot priming",
|
||||
"prompt": "Prefill messages file path",
|
||||
|
|
|
|||
|
|
@ -120,9 +120,6 @@ def cron_list(show_all: bool = False):
|
|||
workdir = job.get("workdir")
|
||||
if workdir:
|
||||
print(f" Workdir: {workdir}")
|
||||
profile = job.get("profile")
|
||||
if profile:
|
||||
print(f" Profile: {profile}")
|
||||
|
||||
# Execution history
|
||||
last_status = job.get("last_status")
|
||||
|
|
@ -221,7 +218,6 @@ def cron_create(args):
|
|||
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
profile=getattr(args, "profile", None),
|
||||
no_agent=getattr(args, "no_agent", False) or None,
|
||||
)
|
||||
if not result.get("success"):
|
||||
|
|
@ -239,8 +235,6 @@ def cron_create(args):
|
|||
print(" Mode: no-agent (script stdout delivered directly)")
|
||||
if job_data.get("workdir"):
|
||||
print(f" Workdir: {job_data['workdir']}")
|
||||
if job_data.get("profile"):
|
||||
print(f" Profile: {job_data['profile']}")
|
||||
print(f" Next run: {result['next_run_at']}")
|
||||
return 0
|
||||
|
||||
|
|
@ -286,7 +280,6 @@ def cron_edit(args):
|
|||
skills=final_skills,
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
profile=getattr(args, "profile", None),
|
||||
no_agent=getattr(args, "no_agent", None),
|
||||
)
|
||||
if not result.get("success"):
|
||||
|
|
@ -307,8 +300,6 @@ def cron_edit(args):
|
|||
print(" Mode: no-agent (script stdout delivered directly)")
|
||||
if updated.get("workdir"):
|
||||
print(f" Workdir: {updated['workdir']}")
|
||||
if updated.get("profile"):
|
||||
print(f" Profile: {updated['profile']}")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2531,6 +2531,65 @@ def systemd_unit_is_current(system: bool = False) -> bool:
|
|||
return norm_installed == norm_expected
|
||||
|
||||
|
||||
def _temp_home_in_service_definition(definition: str) -> str | None:
|
||||
"""Return the temp-dir HERMES_HOME baked into a service definition, or None.
|
||||
|
||||
A generated systemd unit / launchd plist carries the resolved HERMES_HOME
|
||||
in its environment block. If that path lives under the system temp dir,
|
||||
the definition was almost certainly generated by a test/E2E harness that
|
||||
exported a throwaway ``HERMES_HOME=/tmp/...`` — writing it to the real
|
||||
service file silently breaks the user's gateway on the next (re)start:
|
||||
the gateway comes back "active (running)" but pointed at an empty temp
|
||||
home ("No messaging platforms enabled"), deaf to every platform.
|
||||
Seen live 2026-06-11: an E2E guard probe ran ``hermes gateway restart``
|
||||
with ``HERMES_HOME=/tmp/hermes-e2e-<pr>`` exported; the restart path's
|
||||
unit refresh baked the temp path into the production unit and the
|
||||
post-update restart produced a zombie gateway for 7+ hours.
|
||||
|
||||
Matches both systemd ``Environment="HERMES_HOME=..."`` lines and launchd
|
||||
``<key>HERMES_HOME</key><string>...</string>`` pairs.
|
||||
"""
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
candidates = re.findall(r'HERMES_HOME=([^"\n]+)', definition)
|
||||
candidates += re.findall(
|
||||
r"<key>HERMES_HOME</key>\s*<string>(.*?)</string>", definition, flags=re.S
|
||||
)
|
||||
temp_roots = {
|
||||
Path(tempfile.gettempdir()).resolve(),
|
||||
Path("/tmp"),
|
||||
Path("/var/tmp"),
|
||||
Path("/private/tmp"),
|
||||
Path("/private/var/tmp"),
|
||||
}
|
||||
for raw in candidates:
|
||||
try:
|
||||
resolved = Path(raw.strip().strip('"')).resolve()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for root in temp_roots:
|
||||
if resolved == root or root in resolved.parents:
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _refuse_temp_home_service_write(definition: str, kind: str) -> bool:
|
||||
"""Refuse (with guidance) when a service definition carries a temp HERMES_HOME."""
|
||||
temp_home = _temp_home_in_service_definition(definition)
|
||||
if temp_home is None:
|
||||
return False
|
||||
print(
|
||||
f"✗ Refusing to write the gateway {kind}: HERMES_HOME resolves to a "
|
||||
f"temporary directory ({temp_home})."
|
||||
)
|
||||
print(
|
||||
" This usually means a test/E2E environment exported HERMES_HOME. "
|
||||
"Unset it (or run from a clean shell) and retry."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
||||
"""Rewrite the installed systemd unit when the generated definition has changed."""
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
|
|
@ -2561,6 +2620,12 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
|||
):
|
||||
return False
|
||||
|
||||
# Structural variant of the same belt: refuse to bake ANY temp-dir
|
||||
# HERMES_HOME into the unit (manual E2E homes like /tmp/hermes-e2e-NNN
|
||||
# don't carry the pytest markers above but poison the unit identically).
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return False
|
||||
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
print(
|
||||
|
|
@ -2729,10 +2794,11 @@ def systemd_install(
|
|||
return
|
||||
|
||||
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_unit = generate_systemd_unit(system=system, run_as_user=run_as_user)
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return
|
||||
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
|
||||
unit_path.write_text(
|
||||
generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8"
|
||||
)
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
if enable_on_startup:
|
||||
|
|
@ -3067,12 +3133,77 @@ def get_launchd_label() -> str:
|
|||
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
|
||||
|
||||
# Cached launchd domain result — probing is cheap but should only run once per
|
||||
# process invocation (each ``hermes gateway start/stop/status`` call).
|
||||
_resolved_launchd_domain: str | None = None
|
||||
|
||||
|
||||
def _launchd_domain() -> str:
|
||||
# The `user/<uid>` domain (vs the older `gui/<uid>`) is reachable from
|
||||
# non-Aqua/background sessions (SSH, headless, login items) and is the only
|
||||
# one that supports service management on macOS 26+. `gui/<uid>` returns
|
||||
# error 125 ("Domain does not support specified action") there. See #23387.
|
||||
return f"user/{os.getuid()}" # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
|
||||
"""Return the launchd domain that actually manages the gateway service.
|
||||
|
||||
Probes ``gui/<uid>`` first (Aqua sessions), then ``user/<uid>``
|
||||
(Background/SSH sessions). When neither domain contains a loaded
|
||||
service, falls back to ``launchctl managername`` as a heuristic.
|
||||
|
||||
The result is cached for the lifetime of the process so that repeated
|
||||
calls (``start``, ``stop``, ``restart``) use a consistent domain.
|
||||
|
||||
See #40831, #23387.
|
||||
"""
|
||||
global _resolved_launchd_domain
|
||||
if _resolved_launchd_domain is not None:
|
||||
return _resolved_launchd_domain
|
||||
|
||||
uid = os.getuid() # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
|
||||
label = get_launchd_label()
|
||||
gui_domain = f"gui/{uid}"
|
||||
user_domain = f"user/{uid}"
|
||||
|
||||
# 1. Probe gui/<uid> first — in Aqua sessions the service is loaded here.
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "print", f"{gui_domain}/{label}"],
|
||||
check=True,
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
)
|
||||
_resolved_launchd_domain = gui_domain
|
||||
return gui_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 2. Probe user/<uid> — in Background/SSH sessions this is the working domain.
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "print", f"{user_domain}/{label}"],
|
||||
check=True,
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
)
|
||||
_resolved_launchd_domain = user_domain
|
||||
return user_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 3. Neither domain has the service loaded — use managername as heuristic.
|
||||
# Aqua → gui/<uid>, anything else (Background, loginwindow) → user/<uid>.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "managername"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if "Aqua" in (result.stdout or ""):
|
||||
_resolved_launchd_domain = gui_domain
|
||||
return gui_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 4. Default to user/<uid> (matches the pre-probing behavior for
|
||||
# Background/SSH sessions and is the recommended domain on macOS 26+).
|
||||
_resolved_launchd_domain = user_domain
|
||||
return user_domain
|
||||
|
||||
|
||||
# On macOS, exit code 125 ("Domain does not support specified action") and
|
||||
|
|
@ -3297,7 +3428,11 @@ def refresh_launchd_plist_if_needed() -> bool:
|
|||
if not plist_path.exists() or launchd_plist_is_current():
|
||||
return False
|
||||
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return False
|
||||
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
label = get_launchd_label()
|
||||
# Bootout/bootstrap so launchd picks up the new definition
|
||||
subprocess.run(
|
||||
|
|
@ -3330,8 +3465,11 @@ def launchd_install(force: bool = False):
|
|||
return
|
||||
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(generate_launchd_plist())
|
||||
plist_path.write_text(new_plist)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
|
|
@ -3377,9 +3515,12 @@ def launchd_start():
|
|||
|
||||
# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
|
||||
if not plist_path.exists():
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
sys.exit(1)
|
||||
print("↻ launchd plist missing; regenerating service definition")
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
|
||||
|
|
|
|||
|
|
@ -1623,7 +1623,11 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
npm_cwd = _workspace_root(tui_dir)
|
||||
# --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).
|
||||
# See #38772.
|
||||
npm_workspace_args: tuple[str, ...] = ("--workspace", "ui-tui")
|
||||
# When ui-tui/ has its own package-lock.json (e.g. curl install),
|
||||
# _workspace_root() returns tui_dir itself. Passing --workspace in
|
||||
# that case fails because npm cannot find a workspace named "ui-tui"
|
||||
# inside ui-tui/. See #42973.
|
||||
npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui")
|
||||
if termux_startup:
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
|
||||
tui_dir,
|
||||
|
|
@ -4661,7 +4665,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
|||
# graph (including apps/desktop with its Electron + node-pty deps) is never
|
||||
# resolved here. Without --workspace the root package.json's apps/* glob
|
||||
# would pull in desktop on every web build. See #38772.
|
||||
npm_workspace_args: tuple[str, ...] = ("--workspace", "web")
|
||||
# When web/ has its own package-lock.json, _workspace_root() returns
|
||||
# web_dir itself and --workspace would fail. See #42973.
|
||||
npm_workspace_args: tuple[str, ...] = () if npm_cwd == web_dir else ("--workspace", "web")
|
||||
if _is_termux_startup_environment():
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
|
||||
r1 = _run_npm_install_deterministic(
|
||||
|
|
@ -10234,6 +10240,21 @@ def _report_dashboard_status() -> int:
|
|||
return len(pids)
|
||||
|
||||
|
||||
def _dashboard_listening(host: str, port: int) -> bool:
|
||||
"""True when something is accepting TCP connections at host:port.
|
||||
|
||||
Any listener counts — even a 401 response proves a dashboard is up.
|
||||
Used by the unified profile-launch routing to decide attach-vs-start.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host or "127.0.0.1", port), timeout=1.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_dashboard(args):
|
||||
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
||||
# --status: report running dashboards and exit, no deps needed.
|
||||
|
|
@ -10254,6 +10275,65 @@ def cmd_dashboard(args):
|
|||
remaining = _find_stale_dashboard_pids()
|
||||
sys.exit(1 if remaining else 0)
|
||||
|
||||
# ── Unified profile launch routing ────────────────────────────────
|
||||
# The dashboard is a MACHINE management surface: it can read/write any
|
||||
# profile via the per-request ?profile= scoping. Running one dashboard
|
||||
# per profile just fragments that (port collisions, N processes, and a
|
||||
# "which dashboard am I on?" guessing game). So when a NAMED profile
|
||||
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
|
||||
# profiles/), default to the machine dashboard:
|
||||
# - already running → open the browser at ?profile=<name> and exit
|
||||
# - not running → re-exec as the machine dashboard (pinned to the
|
||||
# default profile so _apply_profile_override can't re-route through
|
||||
# the sticky active_profile file) with the launching profile
|
||||
# preselected in the UI's switcher.
|
||||
# `--isolated` opts out and preserves the old per-profile behavior.
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
_launch_profile = get_active_profile_name()
|
||||
except Exception:
|
||||
_launch_profile = "default"
|
||||
|
||||
if (
|
||||
_launch_profile not in ("default", "custom")
|
||||
and not getattr(args, "isolated", False)
|
||||
and not getattr(args, "open_profile", "")
|
||||
):
|
||||
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
|
||||
if _dashboard_listening(args.host, args.port):
|
||||
print(f"Machine dashboard already running on port {args.port}.")
|
||||
print(f" Managing profile '{_launch_profile}': {url}")
|
||||
if not args.no_open:
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
print(
|
||||
f"Routing to the machine dashboard (profile '{_launch_profile}' "
|
||||
f"preselected). Use --isolated for a dedicated per-profile server."
|
||||
)
|
||||
reexec_argv = [
|
||||
sys.executable, "-m", "hermes_cli.main",
|
||||
"-p", "default",
|
||||
"dashboard",
|
||||
"--port", str(args.port),
|
||||
"--host", args.host,
|
||||
"--open-profile", _launch_profile,
|
||||
]
|
||||
if args.no_open:
|
||||
reexec_argv.append("--no-open")
|
||||
if getattr(args, "insecure", False):
|
||||
reexec_argv.append("--insecure")
|
||||
if getattr(args, "skip_build", False):
|
||||
reexec_argv.append("--skip-build")
|
||||
env = os.environ.copy()
|
||||
# Drop the profile HERMES_HOME so the child binds the machine root.
|
||||
env.pop("HERMES_HOME", None)
|
||||
os.execvpe(sys.executable, reexec_argv, env)
|
||||
|
||||
# Attach gui.log early so dashboard startup/build failures are captured in
|
||||
# the same logs directory as every other Hermes surface.
|
||||
try:
|
||||
|
|
@ -10327,6 +10407,7 @@ def cmd_dashboard(args):
|
|||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
allow_public=getattr(args, "insecure", False),
|
||||
initial_profile=getattr(args, "open_profile", "") or "",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -491,15 +491,27 @@ def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> No
|
|||
|
||||
def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
|
||||
requested_norm = _normalize_custom_provider_name(requested_provider or "")
|
||||
if not requested_norm or requested_norm == "custom":
|
||||
if not requested_norm:
|
||||
return None
|
||||
|
||||
# Bare "custom" is normally an incomplete spec — the canonical form is
|
||||
# "custom:<name>" — and is otherwise owned by the model.base_url "bare
|
||||
# custom" trust path. BUT a user may literally name a ``providers:`` (or
|
||||
# legacy ``custom_providers:``) entry "custom" (e.g. ``providers.custom``
|
||||
# pointing at cliproxy). We used to return None here *before* scanning
|
||||
# config, so such an entry was never matched and resolution fell through to
|
||||
# the global default (Codex) — the cause of cron jobs with
|
||||
# ``provider: "custom"`` failing with ``auth_unavailable: providers=codex``.
|
||||
# Fall through to the config scan instead; if no entry is literally named
|
||||
# "custom" it still returns None at the end, preserving the trust path.
|
||||
|
||||
# Raw names should only map to custom providers when they are not already
|
||||
# valid built-in providers or aliases. Explicit menu keys like
|
||||
# ``custom:local`` always target the saved custom provider.
|
||||
# ``custom:local`` always target the saved custom provider. Bare "custom"
|
||||
# is exempt from the shadow check — it is not a built-in to defer to.
|
||||
if requested_norm == "auto":
|
||||
return None
|
||||
if not requested_norm.startswith("custom:"):
|
||||
if requested_norm != "custom" and not requested_norm.startswith("custom:"):
|
||||
try:
|
||||
canonical = auth_mod.resolve_provider(requested_norm)
|
||||
except AuthError:
|
||||
|
|
@ -634,6 +646,20 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
|||
return None
|
||||
|
||||
|
||||
def has_named_custom_provider(requested_provider: str) -> bool:
|
||||
"""Return True when config defines a custom provider matching the request.
|
||||
|
||||
Thin public wrapper around :func:`_get_named_custom_provider` so other
|
||||
modules (e.g. the cronjob tool) can decide whether a provider name will
|
||||
actually resolve to a configured ``providers:`` / ``custom_providers:``
|
||||
entry — without reaching into a private helper or duplicating the scan.
|
||||
"""
|
||||
try:
|
||||
return _get_named_custom_provider(requested_provider) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
|
||||
extra_body = custom_provider.get("extra_body")
|
||||
if not isinstance(extra_body, dict) or not extra_body:
|
||||
|
|
|
|||
|
|
@ -70,10 +70,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
|||
"--workdir",
|
||||
help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).",
|
||||
)
|
||||
cron_create.add_argument(
|
||||
"--profile",
|
||||
help="Hermes profile name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.",
|
||||
)
|
||||
|
||||
# cron edit
|
||||
cron_edit = cron_subparsers.add_parser(
|
||||
|
|
@ -138,10 +134,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
|||
"--workdir",
|
||||
help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.",
|
||||
)
|
||||
cron_edit.add_argument(
|
||||
"--profile",
|
||||
help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.",
|
||||
)
|
||||
|
||||
# lifecycle actions
|
||||
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
|
||||
|
|
|
|||
|
|
@ -45,6 +45,26 @@ def build_dashboard_parser(
|
|||
"where npm may not be available. Pre-build with: cd web && npm run build"
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--isolated",
|
||||
action="store_true",
|
||||
help=(
|
||||
"When launched from a named profile (e.g. `worker dashboard`), run "
|
||||
"a dedicated dashboard server scoped to that profile instead of "
|
||||
"routing to the machine dashboard. Default behavior is unified: "
|
||||
"profile launches attach to (or start) ONE machine-level dashboard "
|
||||
"and preselect the profile in the UI's profile switcher."
|
||||
),
|
||||
)
|
||||
# Internal flag set by the unified-launch re-exec (cmd_dashboard) to
|
||||
# preselect the launching profile in the SPA switcher. Hidden from
|
||||
# --help: users get this behavior automatically via `<profile> dashboard`.
|
||||
dashboard_parser.add_argument(
|
||||
"--open-profile",
|
||||
dest="open_profile",
|
||||
default="",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
|
|
|
|||
|
|
@ -1437,6 +1437,10 @@ def _get_platform_tools(
|
|||
continue
|
||||
if ts_def.get("includes"):
|
||||
continue
|
||||
# Posture toolsets (e.g. ``coding``) are session-level selections made
|
||||
# by agent/coding_context.py — not per-platform capabilities to recover.
|
||||
if ts_def.get("posture"):
|
||||
continue
|
||||
ts_tools = set(resolve_toolset(ts_key))
|
||||
if not ts_tools or not ts_tools.issubset(platform_tool_universe):
|
||||
continue
|
||||
|
|
@ -2178,8 +2182,13 @@ def _toolset_needs_configuration_prompt(
|
|||
tts_cfg = config.get("tts", {})
|
||||
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
|
||||
if ts_key == "web":
|
||||
web_cfg = config.get("web", {})
|
||||
return not isinstance(web_cfg, dict) or "backend" not in web_cfg
|
||||
# Web works out of the box via Parallel's free Search MCP (no key), so
|
||||
# don't force setup just because ``web.backend`` is unset — only prompt
|
||||
# when web isn't actually usable (e.g. an explicit backend configured
|
||||
# without its credentials). Lazy import: web_tools is heavy and most
|
||||
# tools_config callers don't need it.
|
||||
from tools.web_tools import check_web_api_key
|
||||
return not check_web_api_key()
|
||||
if ts_key == "browser":
|
||||
browser_cfg = config.get("browser", {})
|
||||
return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1715,15 +1715,51 @@ class SessionDB:
|
|||
"""Archive or unarchive a session.
|
||||
|
||||
Archived sessions are hidden from the default session list but keep all
|
||||
their messages — this is a soft hide, not a delete. Returns True when a
|
||||
row was updated.
|
||||
their messages — this is a soft hide, not a delete. For compression
|
||||
chains, archive the whole logical conversation. Desktop lists compression
|
||||
roots projected forward to their latest continuation; updating only the
|
||||
displayed tip lets the still-unarchived root resurrect it on refresh.
|
||||
Returns True when at least one row was updated.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"UPDATE sessions SET archived = ? WHERE id = ?",
|
||||
(1 if archived else 0, session_id),
|
||||
"""
|
||||
WITH RECURSIVE
|
||||
ancestors(id) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT parent.id
|
||||
FROM ancestors a
|
||||
JOIN sessions child ON child.id = a.id
|
||||
JOIN sessions parent ON parent.id = child.parent_session_id
|
||||
WHERE parent.end_reason = 'compression'
|
||||
AND child.started_at >= parent.ended_at
|
||||
),
|
||||
descendants(id) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT child.id
|
||||
FROM descendants d
|
||||
JOIN sessions parent ON parent.id = d.id
|
||||
JOIN sessions child ON child.parent_session_id = parent.id
|
||||
WHERE parent.end_reason = 'compression'
|
||||
AND child.started_at >= parent.ended_at
|
||||
),
|
||||
lineage(id) AS (
|
||||
SELECT id FROM ancestors
|
||||
UNION
|
||||
SELECT id FROM descendants
|
||||
)
|
||||
UPDATE sessions
|
||||
SET archived = ?
|
||||
WHERE id IN (SELECT id FROM lineage)
|
||||
""",
|
||||
(session_id, session_id, 1 if archived else 0),
|
||||
)
|
||||
return cursor.rowcount
|
||||
rowcount = cursor.rowcount
|
||||
if rowcount is None or rowcount < 0:
|
||||
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
||||
return rowcount
|
||||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
|
|
@ -3658,6 +3694,48 @@ class SessionDB:
|
|||
self._remove_session_files(sessions_dir, session_id)
|
||||
return deleted
|
||||
|
||||
def delete_session_if_empty(
|
||||
self,
|
||||
session_id: str,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> bool:
|
||||
"""Delete *session_id* only when it never gained resumable content.
|
||||
|
||||
A session is considered empty when it has no messages and no
|
||||
user-assigned title. Used by CLI exit / session-rotation paths so
|
||||
immediately-started-and-quit sessions don't pile up in ``/resume``
|
||||
and ``hermes sessions list`` output. (Pattern ported from
|
||||
google-gemini/gemini-cli#27770.)
|
||||
|
||||
The emptiness check and delete run in one transaction, so a message
|
||||
flushed concurrently by another writer can't be lost. Sessions with
|
||||
children (delegate subagent runs) are preserved — a parent that
|
||||
spawned work is not "empty" even if its own transcript never
|
||||
flushed. Returns True if the session was deleted.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM sessions
|
||||
WHERE id = ?
|
||||
AND title IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sessions child
|
||||
WHERE child.parent_session_id = sessions.id
|
||||
)
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
deleted = self._execute_write(_do)
|
||||
if deleted:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
return bool(deleted)
|
||||
|
||||
def delete_sessions(
|
||||
self,
|
||||
session_ids: List[str],
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "Sessie-databasis is nie beskikbaar nie."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer."
|
||||
list_header: "📋 **Benoemde Sessies**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Hermes Gateway Status**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Sessie-ID:** `{session_id}`"
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Geskep:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "Sitzungsdatenbank nicht verfügbar."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren."
|
||||
list_header: "📋 **Benannte Sitzungen**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Hermes-Gateway-Status**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Sitzungs-ID:** `{session_id}`"
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Erstellt:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -234,6 +234,11 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "Session database not available."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.\nUse quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.\nUse `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.\nFuture messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
|
||||
list_header: "📋 **Named Sessions**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -266,6 +271,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Hermes Gateway Status**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Session ID:** `{session_id}`"
|
||||
title: "**Title:** {title}"
|
||||
created: "**Created:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "Base de datos de sesiones no disponible."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella."
|
||||
list_header: "📋 **Sesiones con nombre**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Estado de Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID de sesión:** `{session_id}`"
|
||||
title: "**Título:** {title}"
|
||||
created: "**Creado:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "Base de données des sessions indisponible."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard."
|
||||
list_header: "📋 **Sessions nommées**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **État de Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID de session :** `{session_id}`"
|
||||
title: "**Titre :** {title}"
|
||||
created: "**Créé :** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -223,6 +223,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "Níl bunachar sonraí na seisiún ar fáil."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí."
|
||||
list_header: "📋 **Seisiúin Ainmnithe**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -255,6 +263,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Stádas Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID Seisiúin:** `{session_id}`"
|
||||
title: "**Teideal:** {title}"
|
||||
created: "**Cruthaithe:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "A munkamenet-adatbázis nem érhető el."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá."
|
||||
list_header: "📋 **Elnevezett munkamenetek**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Hermes Gateway állapot**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Munkamenet-azonosító:** `{session_id}`"
|
||||
title: "**Cím:** {title}"
|
||||
created: "**Létrehozva:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "Database delle sessioni non disponibile."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito."
|
||||
list_header: "📋 **Sessioni con nome**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Stato del Gateway Hermes**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID sessione:** `{session_id}`"
|
||||
title: "**Titolo:** {title}"
|
||||
created: "**Creata:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "セッションデータベースは利用できません。"
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。"
|
||||
list_header: "📋 **名前付きセッション**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Hermes ゲートウェイ状態**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**セッション ID:** `{session_id}`"
|
||||
title: "**タイトル:** {title}"
|
||||
created: "**作成日時:** {timestamp}"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ gateway:
|
|||
|
||||
resume:
|
||||
db_unavailable: "세션 데이터베이스를 사용할 수 없습니다."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "이름이 지정된 세션이 없습니다.\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요."
|
||||
list_header: "📋 **이름이 지정된 세션**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
|
|
@ -251,6 +259,12 @@ gateway:
|
|||
|
||||
status:
|
||||
header: "📊 **Hermes 게이트웨이 상태**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**세션 ID:** `{session_id}`"
|
||||
title: "**제목:** {title}"
|
||||
created: "**생성됨:** {timestamp}"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue