Merge branch 'main' into bb/gui

This commit is contained in:
emozilla 2026-05-17 02:02:28 -04:00
commit 046f0c01cb
45 changed files with 1528 additions and 171 deletions

View file

@ -182,6 +182,7 @@ scripts/run_tests.sh
- 💬 [Discord](https://discord.gg/NousResearch)
- 📚 [Skills Hub](https://agentskills.io)
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Linux desktop-control MCP server for Hermes and other MCP hosts, with AT-SPI accessibility trees, Wayland/X11 input, screenshots, and compositor window targeting.
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
---

View file

@ -129,6 +129,9 @@ class PooledCredential:
def from_dict(cls, provider: str, payload: Dict[str, Any]) -> "PooledCredential":
field_names = {f.name for f in fields(cls) if f.name != "provider"}
data = {k: payload.get(k) for k in field_names if k in payload}
# Rehydrated last_status_at may be an ISO string from to_dict() — normalize to float epoch
if "last_status_at" in data and isinstance(data["last_status_at"], str):
data["last_status_at"] = _parse_absolute_timestamp(data["last_status_at"])
extra = {k: payload[k] for k in _EXTRA_KEYS if k in payload and payload[k] is not None}
data["extra"] = extra
data.setdefault("id", uuid.uuid4().hex[:6])

View file

@ -425,7 +425,7 @@ def build_skill_invocation_message(
loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
if not loaded:
return f"[Failed to load skill: {skill_info['name']}]"
return None
loaded_skill, skill_dir, skill_name = loaded

2
cli.py
View file

@ -12546,6 +12546,7 @@ class HermesCLI:
paste_dir.mkdir(parents=True, exist_ok=True)
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(pasted_text, encoding="utf-8")
logger.info("Collapsed paste #%d: %d lines, %d chars -> %s", _paste_counter[0], line_count + 1, len(pasted_text), paste_file)
placeholder = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
prefix = ""
if buf.cursor_position > 0 and buf.text[buf.cursor_position - 1] != '\n':
@ -12713,6 +12714,7 @@ class HermesCLI:
paste_dir.mkdir(parents=True, exist_ok=True)
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(text, encoding="utf-8")
logger.info("Collapsed paste #%d: %d lines, %d chars -> %s (fallback)", _paste_counter[0], line_count + 1, len(text), paste_file)
_paste_just_collapsed[0] = True
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
buf.cursor_position = len(buf.text)

View file

@ -829,6 +829,9 @@ SUPPORTED_DOCUMENT_TYPES = {
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".ts": "text/plain",
".py": "text/plain",
".sh": "text/plain",
}

View file

@ -3564,6 +3564,43 @@ class DiscordAdapter(BasePlatformAdapter):
return bool(configured)
return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"}
def _discord_allow_any_attachment(self) -> bool:
"""Return whether Discord attachments bypass the SUPPORTED_DOCUMENT_TYPES allowlist.
When True, any uploaded file is cached to disk and surfaced to the
agent as a local path so it can be inspected via terminal / read_file
/ ffprobe / etc. Default False preserves the historical behaviour of
dropping unsupported types with a warning log.
"""
configured = self.config.extra.get("allow_any_attachment")
if configured is not None:
if isinstance(configured, str):
return configured.lower() not in {"false", "0", "no", "off", ""}
return bool(configured)
return os.getenv("DISCORD_ALLOW_ANY_ATTACHMENT", "false").lower() in {"true", "1", "yes", "on"}
def _discord_max_attachment_bytes(self) -> int:
"""Return the per-attachment byte cap. 0 means unlimited.
The whole attachment is held in memory while being written to the
cache, so unlimited carries a real memory cost. Default 32 MiB
matches the historical hardcoded value.
"""
configured = self.config.extra.get("max_attachment_bytes")
if configured is None:
configured = os.getenv("DISCORD_MAX_ATTACHMENT_BYTES")
if configured is None or configured == "":
return 32 * 1024 * 1024
try:
value = int(configured)
except (TypeError, ValueError):
logger.warning(
"[Discord] Invalid max_attachment_bytes value %r, falling back to 32 MiB",
configured,
)
return 32 * 1024 * 1024
return max(0, value)
def _discord_free_response_channels(self) -> set:
"""Return Discord channel IDs where no bot mention is required.
@ -4495,6 +4532,7 @@ class DiscordAdapter(BasePlatformAdapter):
if normalized_content.startswith("/"):
msg_type = MessageType.COMMAND
elif all_attachments:
_allow_any = self._discord_allow_any_attachment()
# Check attachment types
for att in all_attachments:
if att.content_type:
@ -4509,9 +4547,15 @@ class DiscordAdapter(BasePlatformAdapter):
if att.filename:
_, doc_ext = os.path.splitext(att.filename)
doc_ext = doc_ext.lower()
if doc_ext in SUPPORTED_DOCUMENT_TYPES:
if doc_ext in SUPPORTED_DOCUMENT_TYPES or _allow_any:
msg_type = MessageType.DOCUMENT
break
elif _allow_any:
# No content_type at all (rare — discord usually fills it
# in). Treat as a document so downstream pipelines surface
# the path to the agent.
msg_type = MessageType.DOCUMENT
break
# When auto-threading kicked in, route responses to the new thread
effective_channel = auto_threaded_channel or message.channel
@ -4594,31 +4638,48 @@ class DiscordAdapter(BasePlatformAdapter):
if not ext and content_type:
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
ext = mime_to_ext.get(content_type, "")
if ext not in SUPPORTED_DOCUMENT_TYPES:
allow_any_attachment = self._discord_allow_any_attachment()
in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES
if not in_allowlist and not allow_any_attachment:
logger.warning(
"[Discord] Unsupported document type '%s' (%s), skipping",
ext or "unknown", content_type,
)
else:
MAX_DOC_BYTES = 32 * 1024 * 1024
if att.size and att.size > MAX_DOC_BYTES:
max_doc_bytes = self._discord_max_attachment_bytes()
if max_doc_bytes and att.size and att.size > max_doc_bytes:
logger.warning(
"[Discord] Document too large (%s bytes), skipping: %s",
att.size, att.filename,
"[Discord] Document too large (%s bytes > cap %s), skipping: %s",
att.size, max_doc_bytes, att.filename,
)
else:
try:
raw_bytes = await self._cache_discord_document(att, ext)
cached_path = cache_document_from_bytes(
raw_bytes, att.filename or f"document{ext}"
raw_bytes, att.filename or f"document{ext or '.bin'}"
)
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
if in_allowlist:
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
else:
# allow_any_attachment path: untyped file. Use the
# source content_type if discord gave us one,
# otherwise fall back to octet-stream so the agent
# knows it's binary and reaches for terminal tools.
doc_mime = (
content_type
if content_type and content_type != "unknown"
else "application/octet-stream"
)
media_urls.append(cached_path)
media_types.append(doc_mime)
logger.info("[Discord] Cached user document: %s", cached_path)
logger.info(
"[Discord] Cached user %s: %s",
"document" if in_allowlist else "attachment",
cached_path,
)
# Inject text content for plain-text documents (capped at 100 KB)
MAX_TEXT_INJECT_BYTES = 100 * 1024
if ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
if in_allowlist and ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
try:
text_content = raw_bytes.decode("utf-8")
display_name = att.filename or f"document{ext}"
@ -4630,6 +4691,13 @@ class DiscordAdapter(BasePlatformAdapter):
pending_text_injection = injection
except UnicodeDecodeError:
pass
# NOTE: for the allow_any_attachment path we deliberately
# do NOT inject a path string here. ``gateway/run.py``
# already detects DOCUMENT-typed events with
# ``application/octet-stream`` MIME and emits a context
# note with the sandbox-translated cache path via
# ``to_agent_visible_cache_path()`` (important for
# Docker/Modal terminal backends).
except Exception as e:
logger.warning(
"[Discord] Failed to cache document %s: %s",

View file

@ -54,6 +54,13 @@ from gateway.platforms.base import (
logger = logging.getLogger(__name__)
_BUILTIN_DELIVER_PLATFORMS = {
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
"qqbot", "yuanbao",
}
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8644
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
@ -238,12 +245,6 @@ class WebhookAdapter(BasePlatformAdapter):
# Cross-platform delivery — any platform with a gateway adapter.
# Check both built-in names and plugin-registered platforms.
_BUILTIN_DELIVER_PLATFORMS = {
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
"qqbot", "yuanbao",
}
_is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS
if not _is_known_platform:
try:

View file

@ -1313,6 +1313,18 @@ DEFAULT_CONFIG = {
# list_roles, member_info, search_members, fetch_messages, list_pins,
# pin_message, unpin_message, create_thread, add_role, remove_role.
"server_actions": "",
# Accept arbitrary attachment file types (not just SUPPORTED_DOCUMENT_TYPES).
# When True, any uploaded file is cached to disk with mime
# application/octet-stream and the path is surfaced to the agent so it
# can use terminal/read_file/etc. against it. Default False preserves
# the historical allowlist behaviour.
# Env override: DISCORD_ALLOW_ANY_ATTACHMENT.
"allow_any_attachment": False,
# Maximum bytes per attachment the gateway will cache. The whole file
# is held in memory while being written, so unlimited uploads carry a
# real memory cost. Default 32 MiB matches the historical hardcoded
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
"max_attachment_bytes": 33554432,
},
# WhatsApp platform settings (gateway mode)

View file

@ -5,6 +5,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup]
"""
import asyncio
import logging
import os
import shutil
import signal
@ -38,6 +39,7 @@ from hermes_cli.setup import (
)
from hermes_cli.colors import Colors, color
logger = logging.getLogger(__name__)
# =============================================================================
# Process Management (for manual gateway runs)

View file

@ -1403,7 +1403,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
sev = getattr(args, "severity", None)
if sev:
for tid in list(diags_by_task.keys()):
kept = [d for d in diags_by_task[tid] if d.severity == sev]
kept = [d for d in diags_by_task[tid] if kd.SEVERITY_ORDER.index(d.severity) >= kd.SEVERITY_ORDER.index(sev)]
if kept:
diags_by_task[tid] = kept
else:

View file

@ -1089,7 +1089,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
return [node, str(bundled)], bundled.parent
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
# --dev flow: npm install if needed, then tsx src/entry.tsx (no build).
# --dev flow: npm install if needed, then tsx src/entry.tsx.
if _tui_need_npm_install(tui_dir):
npm = _node_bin("npm")
if not os.environ.get("HERMES_QUIET"):
@ -1111,10 +1111,30 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
sys.exit(1)
if tui_dev:
# Keep the local @hermes/ink package exports in sync with source.
# --dev runs src/entry.tsx directly, but @hermes/ink resolves through
# packages/hermes-ink/dist/entry-exports.js. If that dist bundle is
# stale after a pull, newer hooks/components can exist in src while
# being missing at runtime (e.g. useCursorAdvance). Prebuild it here.
npm = _node_bin("npm")
ink_dir = tui_dir / "packages" / "hermes-ink"
result = subprocess.run(
[npm, "run", "build"],
cwd=str(ink_dir),
capture_output=True,
text=True,
)
if result.returncode != 0:
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
preview = "\n".join(combined.splitlines()[-30:])
print("TUI dev prebuild failed.")
if preview:
print(preview)
sys.exit(1)
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
if tsx.exists():
return [str(tsx), "src/entry.tsx"], tui_dir
npm = _node_bin("npm")
return [npm, "start"], tui_dir
# Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs.

View file

@ -58,8 +58,8 @@ def _read_message_body(
if file_path == "-":
return sys.stdin.read()
try:
return Path(file_path).read_text()
except OSError as exc:
return Path(file_path).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr)
sys.exit(_USAGE_EXIT)

View file

@ -94,6 +94,7 @@ deepseek = DeepSeekProfile(
"deepseek-reasoner",
),
base_url="https://api.deepseek.com/v1",
default_aux_model="deepseek-chat",
)
register_provider(deepseek)

View file

@ -116,6 +116,13 @@ def _parse_bool(value: Any, *, default: bool = False) -> bool:
return default
def _coerce_port(value: Any, *, default: int = _DEFAULT_PORT) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
class _StaticAccessTokenProvider:
"""Minimal token-provider shim so outbound Graph delivery can reuse the shared client."""
@ -623,7 +630,9 @@ class TeamsAdapter(BasePlatformAdapter):
self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "")
self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "")
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")
self._port = int(extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)))
self._port = _coerce_port(
extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT))
)
self._app: Optional["App"] = None
self._runner: Optional["web.AppRunner"] = None
self._dedup = MessageDeduplicator(max_size=1000)

View file

@ -2022,7 +2022,7 @@ class AIAgent:
try:
_mem_provider_name = mem_config.get("provider", "") if mem_config else ""
if _mem_provider_name:
if _mem_provider_name and _mem_provider_name.strip():
from agent.memory_manager import MemoryManager as _MemoryManager
from plugins.memory import load_memory_provider as _load_mem
self._memory_manager = _MemoryManager()
@ -4347,6 +4347,21 @@ class AIAgent:
# owns the loop and the agent-loop tools dispatch.
if _parent_api_mode == "codex_app_server":
_parent_api_mode = "codex_responses"
# skip_memory=True keeps the review fork from
# touching external memory plugins (honcho, mem0,
# supermemory, etc.). Without it, the fork's
# __init__ rebuilds its own _memory_manager from
# config, scoped to the parent's session_id, and
# run_conversation() then leaks the harness prompt
# into the user's real memory namespace via three
# ingestion sites: on_turn_start (cadence + turn
# message), prefetch_all (recall query), and
# sync_all (harness prompt + review output recorded
# as a (user, assistant) turn pair). Built-in
# MEMORY.md / USER.md state is re-bound from the
# parent below so memory(action="add") writes from
# the review still land on disk; the review just
# has zero side effects on external providers.
review_agent = AIAgent(
model=self.model,
max_iterations=16,
@ -4358,6 +4373,7 @@ class AIAgent:
api_key=_parent_runtime.get("api_key") or None,
credential_pool=getattr(self, "_credential_pool", None),
parent_session_id=self.session_id,
skip_memory=True,
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"
@ -9169,6 +9185,7 @@ class AIAgent:
self.model, base_url=self.base_url,
api_key=self.api_key, provider=self.provider,
config_context_length=getattr(self, "_config_context_length", None),
custom_providers=self._custom_providers,
)
self.context_compressor.update_model(
model=self.model,
@ -10096,6 +10113,7 @@ class AIAgent:
"openai/",
"x-ai/",
"google/gemini-2",
"google/gemma-4",
"qwen/qwen3",
"tencent/hy3-preview",
"xiaomi/",
@ -10393,12 +10411,16 @@ class AIAgent:
Kimi ``/coding`` and Moonshot thinking mode both require
``reasoning_content`` on every assistant tool-call message; omitting
it causes the next replay to fail with HTTP 400.
Also detects Kimi models served through third-party providers (e.g.
ollama-cloud) by matching ``kimi`` in the model name.
"""
return (
self.provider in {"kimi-coding", "kimi-coding-cn"}
or base_url_host_matches(self.base_url, "api.kimi.com")
or base_url_host_matches(self.base_url, "moonshot.ai")
or base_url_host_matches(self.base_url, "moonshot.cn")
or "kimi" in (self.model or "").lower()
)
def _needs_deepseek_tool_reasoning(self) -> bool:

File diff suppressed because it is too large Load diff

View file

@ -1086,6 +1086,33 @@ AUTHOR_MAP = {
"nightcityblade@gmail.com": "nightcityblade", # PR #24138 (docs voice/tts table)
"pol.kuijken@gmail.com": "polkn", # PR #6136 salvage (skill_view collision refusal)
"robin@soal.org": "rewbs",
# batch salvage (May 2026 LHF run)
"sauravsejal40@gmail.com": "Saurav0989", # PR #27071 (docs: hermes-eval community link)
"220110965+Saurav0989@users.noreply.github.com": "Saurav0989",
"aviarchi1994@gmail.com": "avifenesh", # PR #25902 (docs: computer-use-linux MCP)
"55848801+avifenesh@users.noreply.github.com": "avifenesh",
"279959838+BROCCOLO1D@users.noreply.github.com": "BROCCOLO1D", # PR #26796 (docs: spotify + HA)
"m@matthewlai.ca": "matthewlai", # PR #25293 (feat: gemma 4 reasoning allowlist)
"4296245+matthewlai@users.noreply.github.com": "matthewlai",
"109617724+0xchainer@users.noreply.github.com": "0xchainer", # PR #27154/27138/27147 salvage
"201800237+kronexoi@users.noreply.github.com": "kronexoi", # PR #27167 salvage (Teams port fallback)
# batch salvage (May 2026 LHF run, group 2)
"shellybotmoyer@example.com": "shellybotmoyer", # PR #26661 (kanban --severity >=)
"coulson@shellybotmoyer.com": "shellybotmoyer", # PR #25576 (credential_pool ISO rehydrate)
"258858106+shellybotmoyer@users.noreply.github.com": "shellybotmoyer",
"33156212+ether-btc@users.noreply.github.com": "ether-btc", # PR #26632 (memory provider whitespace guard)
"Bloomtonjovish@gmail.com": "LifeJiggy", # PR #26516 (paste collapse logging)
"141562589+LifeJiggy@users.noreply.github.com": "LifeJiggy",
"beastant1@gmail.com": "nekwo", # PR #26481 (PS5.1 UTF-8 BOM)
"43717185+nekwo@users.noreply.github.com": "nekwo",
"67979730+flooryyyy@users.noreply.github.com": "flooryyyy", # PR #26374 (tool_trace error detection)
"188585318+dgians@users.noreply.github.com": "dgians", # PR #26034 (.ts/.py/.sh docs types)
"zealy@tz.co": "dgians", # PR #26034 (bot-committed by zealy-tzco under dgians' PR)
"mottei.survive@gmail.com": "flanny7", # PR #27030 (setup_open_webui python var)
"20530505+flanny7@users.noreply.github.com": "flanny7",
"hermesagent26@gmail.com": "hermesagent26", # PR #26438 (kimi model-name reasoning pad)
"276067471+hermesagent26@users.noreply.github.com": "hermesagent26",
"71590782+kriscolab@users.noreply.github.com": "kriscolab", # PR #26926 (deepseek default_aux_model)
}

View file

@ -163,8 +163,8 @@ install_open_webui() {
"$py" -m venv "$OPEN_WEBUI_VENV"
# shellcheck disable=SC1090
source "$OPEN_WEBUI_VENV/bin/activate"
python -m pip install --upgrade pip setuptools wheel
python -m pip install open-webui
"$py" -m pip install --upgrade pip setuptools wheel
"$py" -m pip install open-webui
}
write_launcher() {

View file

@ -0,0 +1,134 @@
# Smoke tests for the install.ps1 stage protocol.
#
# Run from a PowerShell prompt:
#
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1
#
# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest,
# unknown -Stage handling). They DO NOT actually run any install stages --
# those have heavy side effects (winget, git clone, pip install, PATH writes)
# and are out of scope for a unit smoke test. All three metadata commands
# below return without invoking Main / Invoke-AllStages.
#
# To exercise real install stages, drive the script from a clean VM.
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot "scripts\install.ps1"
if (-not (Test-Path $installScript)) {
throw "Could not locate install.ps1 at $installScript"
}
$failures = 0
function Assert-Equal {
param([Parameter(Mandatory=$true)] $Expected,
[Parameter(Mandatory=$true)] $Actual,
[Parameter(Mandatory=$true)] [string]$Label)
if ($Expected -ne $Actual) {
Write-Host "FAIL: $Label" -ForegroundColor Red
Write-Host " expected: $Expected"
Write-Host " actual: $Actual"
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
function Assert-True {
param([Parameter(Mandatory=$true)] $Condition,
[Parameter(Mandatory=$true)] [string]$Label)
if (-not $Condition) {
Write-Host "FAIL: $Label" -ForegroundColor Red
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
# -----------------------------------------------------------------------------
# Test: -ProtocolVersion emits a single integer
# -----------------------------------------------------------------------------
Write-Host ""
Write-Host "-- -ProtocolVersion --"
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0"
Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)"
# -----------------------------------------------------------------------------
# Test: -Manifest emits valid JSON with expected shape
# -----------------------------------------------------------------------------
Write-Host ""
Write-Host "-- -Manifest --"
$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0"
$manifest = $null
try {
$manifest = $manifestJson | ConvertFrom-Json
Assert-True $true -Label "-Manifest output parses as JSON"
} catch {
Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)"
}
if ($manifest) {
Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) `
-Label "manifest.protocol_version is an integer"
Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty"
# Every stage has the four required fields
$allValid = $true
foreach ($stage in $manifest.stages) {
foreach ($field in @("name", "title", "category", "needs_user_input")) {
if (-not ($stage.PSObject.Properties.Name -contains $field)) {
Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red
$allValid = $false
}
}
}
Assert-True $allValid -Label "every stage has name/title/category/needs_user_input"
# Specific stage names that the GUI driver will rely on
$names = $manifest.stages | ForEach-Object { $_.name }
foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) {
Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'"
}
# The two known-interactive stages must declare needs_user_input
$interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name }
Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input"
Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input"
}
# -----------------------------------------------------------------------------
# Test: unknown stage name -> exit 2, structured JSON error
# -----------------------------------------------------------------------------
Write-Host ""
Write-Host "-- -Stage with unknown name --"
$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist"
Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2"
$errFrame = $null
try {
$errFrame = $errOutput | ConvertFrom-Json
Assert-True $true -Label "unknown-stage output parses as JSON"
} catch {
Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)"
}
if ($errFrame) {
Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false"
Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name"
Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why"
}
# -----------------------------------------------------------------------------
# Summary
# -----------------------------------------------------------------------------
Write-Host ""
if ($failures -gt 0) {
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
exit 1
} else {
Write-Host "All smoke tests passed." -ForegroundColor Green
exit 0
}

View file

@ -466,6 +466,14 @@ Generate some audio.
msg = build_skill_invocation_message("/nonexistent")
assert msg is None
def test_returns_none_when_skill_load_fails(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "broken-skill")
scan_skill_commands()
with patch("agent.skill_commands._load_skill_payload", return_value=None):
msg = build_skill_invocation_message("/broken-skill", "do stuff")
assert msg is None
def test_uses_shared_skill_loader_for_secure_setup(self, tmp_path, monkeypatch):
monkeypatch.delenv("TENOR_API_KEY", raising=False)
calls = []

View file

@ -384,3 +384,148 @@ class TestIncomingDocumentHandling:
assert event.message_type == MessageType.PHOTO
assert event.media_urls == ["/tmp/cached_image.png"]
assert event.media_types == ["image/png"]
class TestAllowAnyAttachment:
"""Cover the discord.allow_any_attachment config flag.
With the flag off (default), unknown file types are dropped. With it on,
they get cached and surfaced to the agent as DOCUMENT events with
application/octet-stream MIME so gateway/run.py emits a path-pointing
context note.
"""
@pytest.mark.asyncio
async def test_unknown_type_skipped_by_default(self, adapter):
"""Default (flag off): unknown extension is dropped.
With no text + no cached media, the adapter may legitimately decline
to dispatch the event at all, so we don't assert on call_args here —
we just verify the file wasn't cached.
"""
with _mock_aiohttp_download(b"should not be cached"):
msg = make_message([
make_attachment(filename="weird.xyz", content_type="application/x-custom")
])
await adapter._handle_message(msg)
if adapter.handle_message.call_args is not None:
event = adapter.handle_message.call_args[0][0]
assert event.media_urls == []
@pytest.mark.asyncio
async def test_unknown_type_cached_when_flag_on(self, adapter):
"""Flag on: unknown extension is cached as application/octet-stream."""
adapter.config.extra["allow_any_attachment"] = True
with _mock_aiohttp_download(b"\x00\x01\x02 binary payload"):
msg = make_message([
make_attachment(filename="weird.xyz", content_type="application/x-custom")
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert len(event.media_urls) == 1
assert os.path.exists(event.media_urls[0])
# Falls back to the source content_type when we have one.
assert event.media_types == ["application/x-custom"]
assert event.message_type == MessageType.DOCUMENT
# We deliberately do NOT inline arbitrary bytes — run.py emits the
# path-pointing note based on DOCUMENT + octet-stream MIME.
assert "[Content of" not in (event.text or "")
@pytest.mark.asyncio
async def test_unknown_type_no_content_type_becomes_octet_stream(self, adapter):
"""Flag on + no content_type from discord: MIME falls back to octet-stream."""
adapter.config.extra["allow_any_attachment"] = True
with _mock_aiohttp_download(b"raw bytes"):
msg = make_message([
make_attachment(filename="mystery.bin", content_type=None)
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert event.message_type == MessageType.DOCUMENT
assert event.media_types == ["application/octet-stream"]
@pytest.mark.asyncio
async def test_max_attachment_bytes_caps_uploads(self, adapter):
"""discord.max_attachment_bytes overrides the historical 32 MiB cap."""
adapter.config.extra["allow_any_attachment"] = True
adapter.config.extra["max_attachment_bytes"] = 1024 # 1 KiB
msg = make_message([
make_attachment(
filename="too_big.xyz",
content_type="application/x-custom",
size=2048,
)
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert event.media_urls == []
@pytest.mark.asyncio
async def test_max_attachment_bytes_zero_means_unlimited(self, adapter):
"""max_attachment_bytes=0 disables the size cap entirely."""
adapter.config.extra["allow_any_attachment"] = True
adapter.config.extra["max_attachment_bytes"] = 0
# 64 MiB — would normally exceed the historical 32 MiB hardcoded cap.
with _mock_aiohttp_download(b"x" * 16):
msg = make_message([
make_attachment(
filename="huge.xyz",
content_type="application/x-custom",
size=64 * 1024 * 1024,
)
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert len(event.media_urls) == 1
@pytest.mark.asyncio
async def test_allowlisted_doc_unchanged_when_flag_on(self, adapter):
"""Flag on must not change handling of types already in SUPPORTED_DOCUMENT_TYPES.
A .txt should still get its content inlined (the historical behavior),
and the MIME should still be the canonical text/plain not whatever
discord guessed.
"""
adapter.config.extra["allow_any_attachment"] = True
file_content = b"still a text file"
with _mock_aiohttp_download(file_content):
msg = make_message(
attachments=[make_attachment(filename="notes.txt", content_type="text/plain")],
content="check this",
)
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert "[Content of notes.txt]:" in event.text
assert "still a text file" in event.text
assert event.media_types == ["text/plain"]
def test_helper_reads_env_fallback(self, adapter, monkeypatch):
"""Helper falls back to DISCORD_ALLOW_ANY_ATTACHMENT env var."""
assert adapter._discord_allow_any_attachment() is False
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true")
assert adapter._discord_allow_any_attachment() is True
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "no")
assert adapter._discord_allow_any_attachment() is False
def test_helper_config_overrides_env(self, adapter, monkeypatch):
"""config.yaml setting wins over env var."""
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true")
adapter.config.extra["allow_any_attachment"] = False
assert adapter._discord_allow_any_attachment() is False
def test_max_bytes_helper_invalid_value_falls_back(self, adapter):
"""Garbage in max_attachment_bytes config falls back to 32 MiB."""
adapter.config.extra["max_attachment_bytes"] = "not-a-number"
assert adapter._discord_max_attachment_bytes() == 32 * 1024 * 1024

View file

@ -283,6 +283,17 @@ class TestTeamsAdapterInit:
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter._port == 5000
def test_invalid_port_from_extra_falls_back_to_default(self):
adapter = TeamsAdapter(
_make_config(client_id="id", client_secret="secret", tenant_id="tenant", port="abc")
)
assert adapter._port == 3978
def test_invalid_port_from_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("TEAMS_PORT", "abc")
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter._port == 3978
def test_platform_value(self):
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter.platform.value == "teams"

View file

@ -559,3 +559,9 @@ class TestStopProfileGateway:
assert calls["kill"] == 1 # one SIGTERM
assert calls["alive_probes"] == 20 # 20 liveness polls over the 2s window
assert calls["remove"] == 0
def test_module_has_logger():
"""Verify module has a logger instance (regression guard for #27154)."""
assert hasattr(gateway, "logger")
assert gateway.logger.name == "hermes_cli.gateway"

View file

@ -173,6 +173,19 @@ def test_file_not_found_is_usage_error(fake_tool, capsys, monkeypatch):
assert "cannot read" in err.lower()
def test_file_decode_error_is_usage_error(fake_tool, capsys, monkeypatch, tmp_path):
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
bad = tmp_path / "bad-bytes.bin"
bad.write_bytes(b"\xff\xfe\x00")
args = _parse(["--to", "telegram", "--file", str(bad)])
with pytest.raises(SystemExit) as exc:
send_cmd.cmd_send(args)
assert exc.value.code == 2
err = capsys.readouterr().err
assert "cannot read" in err.lower()
def test_tool_error_returns_failure_exit(monkeypatch, capsys):
import sys as _sys
import types as _types

View file

@ -523,6 +523,34 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
assert env["NODE_ENV"] == "production"
def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path):
tui_dir = tmp_path / "ui-tui"
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
ink_dir = tui_dir / "packages" / "hermes-ink"
tsx.parent.mkdir(parents=True)
ink_dir.mkdir(parents=True)
tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8")
monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None)
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False)
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}")
calls = []
def fake_run(cmd, cwd=None, **_kwargs):
calls.append((cmd, cwd))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=True)
assert argv == [str(tsx), "src/entry.tsx"]
assert cwd == tui_dir
assert calls == [(["/usr/bin/npm", "run", "build"], str(ink_dir))]
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
import hermes_cli.main as main_mod

View file

@ -182,3 +182,26 @@ class TestDeepSeekFullKwargsIntegration:
)
assert "reasoning_effort" not in kwargs
assert "extra_body" not in kwargs or "thinking" not in kwargs.get("extra_body", {})
class TestDeepSeekAuxModel:
"""DeepSeek aux model is set on the profile so users stop seeing the
bogus 'No auxiliary LLM provider configured' warning (#26924).
Pinned at the profile layer rather than the legacy
`_API_KEY_PROVIDER_AUX_MODELS_FALLBACK` dict new providers are
expected to set `default_aux_model` on `ProviderProfile`, and the
fallback dict only exists for providers that predate the profiles
system.
"""
def test_profile_advertises_deepseek_chat(self, deepseek_profile):
assert deepseek_profile.default_aux_model == "deepseek-chat"
def test_consumer_api_returns_deepseek_chat(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("deepseek") == "deepseek-chat"
def test_consumer_api_returns_non_empty(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("deepseek") != ""

View file

@ -193,3 +193,51 @@ def test_background_review_summary_is_attributed_to_self_improvement_loop(monkey
assert captured_bg_callback[0].startswith("💾 Self-improvement review:"), (
captured_bg_callback[0]
)
def test_background_review_fork_skips_external_memory_plugins(monkeypatch):
"""The background review fork must NOT touch external memory plugins.
Without skip_memory=True on the fork constructor, AIAgent.__init__
rebuilds its own _memory_manager from config, scoped to the parent's
session_id. The review fork's run_conversation() then leaks the
harness prompt into the user's real memory namespace via three
ingestion sites: on_turn_start (cadence + turn message),
prefetch_all (recall query), and sync_all (harness prompt + review
output recorded as a (user, assistant) turn pair). The fix is a
single kwarg on the fork constructor this test guards it.
"""
captured_kwargs: dict = {}
class FakeReviewAgent:
def __init__(self, **kwargs):
captured_kwargs.update(kwargs)
self._session_messages = []
def run_conversation(self, **kwargs):
pass
def shutdown_memory_provider(self):
pass
def close(self):
pass
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread)
agent = _bare_agent()
AIAgent._spawn_background_review(
agent,
messages_snapshot=[{"role": "user", "content": "hello"}],
review_memory=True,
)
assert captured_kwargs.get("skip_memory") is True, (
"Background review fork must be constructed with skip_memory=True "
"so AIAgent.__init__ does not rebuild a _memory_manager wired to "
"external plugins (honcho, mem0, supermemory, ...). Without this "
"the fork leaks harness prompts into the user's real memory "
"namespace via on_turn_start / prefetch_all / sync_all."
)

View file

@ -1649,7 +1649,7 @@ def _run_single_child(
trace_by_id[tc_id] = entry_t
elif msg.get("role") == "tool":
content = msg.get("content", "")
is_error = bool(content and "error" in content[:80].lower())
is_error = _looks_like_error_output(content)
result_meta = {
"result_bytes": len(content),
"status": "error" if is_error else "ok",

View file

@ -44,7 +44,6 @@ import queue
import re
import shlex
import shutil
import signal
import subprocess
import tempfile
import threading

View file

@ -52,6 +52,50 @@ describe('forceTruecolor', () => {
)
})
it('downgrades Apple Terminal when truecolor is only advertised by env', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(true)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('keeps non-Apple terminals untouched when they advertise truecolor', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'vscode'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'vscode',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => {
await withCleanEnv(
() => {
@ -79,6 +123,30 @@ describe('forceTruecolor', () => {
)
})
it('lets explicit opt-in keep Apple truecolor advertisement', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
HERMES_TUI_TRUECOLOR: '1'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('respects NO_COLOR', async () => {
await withCleanEnv(
() => {

View file

@ -8,12 +8,15 @@ import {
estimateRows,
estimateTokensRough,
fmtK,
hasAnsi,
isToolTrailResultLine,
lastCotTrailIndex,
parseToolTrailResultLine,
pasteTokenLabel,
sanitizeAnsiForRender,
sameToolTrailGroup,
splitToolDuration,
stripAnsi,
thinkingPreview
} from '../lib/text.js'
@ -84,6 +87,46 @@ describe('estimateTokensRough', () => {
})
})
describe('ANSI sanitizers', () => {
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
it('strips CSI/OSC/control bytes from plain previews', () => {
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD`
expect(stripAnsi(sample)).toBe('ABCD')
})
it('strips incomplete CSI prefixes and carriage returns', () => {
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE`
expect(stripAnsi(sample)).toBe('ABDE')
})
it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => {
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC`
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`)
})
it('keeps valid SGR while removing dangling CSI and carriage returns', () => {
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD`
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`)
})
it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => {
const sample = `A${ESC}(0B${ESC}%GC${ESC})0D`
expect(stripAnsi(sample)).toBe('ABCD')
expect(sanitizeAnsiForRender(sample)).toBe('ABCD')
})
it('detects non-CSI escape prefixes too', () => {
expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true)
})
})
describe('thinkingPreview', () => {
it('adds paragraph breaks before markdown thinking headings', () => {
const raw =

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js'
import { canFastAppendShape, canFastBackspaceShape, supportsFastEchoTerminal } from '../components/textInput.js'
// The fast-echo path bypasses Ink and writes characters directly to stdout
// for the common case of typing plain English at the end of the line. These
@ -172,3 +172,14 @@ describe('canFastBackspaceShape', () => {
expect(canFastBackspaceShape('hello ', 'hello '.length)).toBe(true)
})
})
describe('supportsFastEchoTerminal', () => {
it('disables fast-echo in Apple Terminal', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
})
it('keeps fast-echo enabled in VS Code and unknown terminals', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
})
})

View file

@ -12,6 +12,7 @@ import {
compactPreview,
hasAnsi,
isPasteBackedText,
sanitizeAnsiForRender,
stripAnsi
} from '../lib/text.js'
import type { Theme } from '../theme.js'
@ -85,13 +86,14 @@ export const MessageLine = memo(function MessageLine({
if (msg.role === 'tool') {
const maxChars = Math.max(24, cols - 14)
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return (
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{msg.text}</Ansi>
<Ansi>{safeAnsi}</Ansi>
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
@ -129,13 +131,13 @@ export const MessageLine = memo(function MessageLine({
{msg.text.length.toLocaleString()} chars
</Text>
</Box>
{systemOpen && <Ansi>{msg.text}</Ansi>}
{systemOpen && <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>}
</Box>
)
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{msg.text}</Ansi>
return <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>
}
if (msg.role === 'assistant') {

View file

@ -283,6 +283,12 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?:
return ASCII_PRINTABLE_RE.test(removed)
}
export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
// Terminal.app still shows paint/cursor artifacts under the fast-echo
// bypass path. Fall back to the normal Ink render path there.
return (env.TERM_PROGRAM ?? '').trim() !== 'Apple_Terminal'
}
function renderWithCursor(value: string, cursor: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
@ -559,7 +565,7 @@ export function TextInput({
}, 16)
}
const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY
const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY
const canFastAppend = (current: string, cursor: number, text: string) =>
canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current)

View file

@ -19,12 +19,42 @@ export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): bool
return TRUE_RE.test(override)
}
const isAppleTerminal = (env: NodeJS.ProcessEnv = process.env) => (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal'
const isAdvertisedTruecolor = (env: NodeJS.ProcessEnv = process.env) => {
const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase()
const forceColor = (env.FORCE_COLOR ?? '').trim()
return colorTerm === 'truecolor' || colorTerm === '24bit' || forceColor === '3'
}
export function shouldDowngradeAppleTerminalTruecolor(env: NodeJS.ProcessEnv = process.env): boolean {
if (!isAppleTerminal(env)) {
return false
}
if (shouldForceTruecolor(env)) {
return false
}
return isAdvertisedTruecolor(env)
}
if (shouldForceTruecolor()) {
if (!process.env.COLORTERM) {
process.env.COLORTERM = 'truecolor'
}
process.env.FORCE_COLOR = '3'
} else if (shouldDowngradeAppleTerminalTruecolor()) {
// Terminal.app may advertise truecolor even when RGB SGR paths render
// incorrectly. Keep Hermes on the safer TERM-driven 256-color path unless
// users explicitly opt back in via HERMES_TUI_TRUECOLOR=1.
delete process.env.COLORTERM
if ((process.env.FORCE_COLOR ?? '').trim() === '3') {
delete process.env.FORCE_COLOR
}
}
export {}

View file

@ -9,12 +9,40 @@ import { VERBS } from '../content/verbs.js'
import type { ThinkingMode } from '../types.js'
const ESC = String.fromCharCode(27)
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
const BEL = String.fromCharCode(7)
const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g')
const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g')
const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g')
const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g')
const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g
const WS_RE = /\s+/g
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
export const stripAnsi = (s: string) =>
s
.replace(ANSI_OSC_RE, '')
.replace(ANSI_STRING_RE, '')
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_CSI_RE, '')
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
.replace(ANSI_STRAY_ESC_RE, '')
.replace(CONTROL_RE, '')
export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`)
export const sanitizeAnsiForRender = (s: string) =>
s
.replace(ANSI_OSC_RE, '')
.replace(ANSI_STRING_RE, '')
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : ''))
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
.replace(ANSI_STRAY_ESC_RE, '')
.replace(CONTROL_RE, '')
export const hasAnsi = (s: string) => s.includes(ESC)
const renderEstimateLine = (line: string) => {
const trimmed = line.trim()

View file

@ -1106,13 +1106,17 @@ hermes claw migrate --source /home/user/old-openclaw
hermes dashboard [options]
```
Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `pip install hermes-agent[web]` (FastAPI + Uvicorn). See [Web Dashboard](/docs/user-guide/features/web-dashboard) for full documentation.
Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `pip install hermes-agent[web]` (FastAPI + Uvicorn). The embedded browser Chat tab requires `--tui` plus the `pty` extra. See [Web Dashboard](/docs/user-guide/features/web-dashboard) for full documentation.
| Option | Default | Description |
|--------|---------|-------------|
| `--port` | `9119` | Port to run the web server on |
| `--host` | `127.0.0.1` | Bind address |
| `--no-open` | — | Don't auto-open the browser |
| `--tui` | off | Enable the in-browser Chat tab by running `hermes --tui` behind a PTY/WebSocket bridge. Requires `pip install 'hermes-agent[web,pty]'` and a POSIX PTY environment such as Linux, macOS, or WSL2. |
| `--insecure` | off | Allow binding to non-localhost hosts. Exposes dashboard credentials on the network; use only behind trusted network controls. |
| `--stop` | — | Stop running `hermes dashboard` processes and exit. |
| `--status` | — | List running `hermes dashboard` processes and exit. |
```bash
# Default — opens browser to http://127.0.0.1:9119
@ -1120,6 +1124,9 @@ hermes dashboard
# Custom port, no browser
hermes dashboard --port 8080 --no-open
# Enable the browser Chat tab
hermes dashboard --tui
```
## `hermes profile`

View file

@ -258,6 +258,8 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |
| `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |
| `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |
| `DISCORD_ALLOW_ANY_ATTACHMENT` | When `true`, accept attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached and surfaced to the agent as a local path so it can inspect them via `terminal` / `read_file` / `ffprobe`. Default `false`. |
| `DISCORD_MAX_ATTACHMENT_BYTES` | Maximum bytes per attachment the gateway will cache. Default `33554432` (32 MiB). Set to `0` for no cap (attachments are held in memory while being written). |
| `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) |
| `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds |
| `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading |

View file

@ -1667,6 +1667,7 @@ delegation:
# provider: "openrouter" # Override provider (empty = inherit parent)
# base_url: "http://localhost:1234/v1" # Direct OpenAI-compatible endpoint (takes precedence over provider)
# api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY)
# api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect.
max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var.
max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels.
orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth.
@ -1676,6 +1677,8 @@ delegation:
**Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only.
**Wire protocol (`api_mode`):** Hermes auto-detects the wire protocol from `delegation.base_url` (e.g. paths ending in `/anthropic``anthropic_messages`; Codex / native Anthropic / Kimi-coding hostnames keep their existing detection). For endpoints the heuristic can't classify — for example Azure AI Foundry, MiniMax, Zhipu GLM, or LiteLLM proxies fronting an Anthropic-shaped backend — set `delegation.api_mode` explicitly to one of `chat_completions`, `codex_responses`, or `anthropic_messages`. Leave it empty (the default) to keep auto-detection.
The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `copilot`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed.
**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter).

View file

@ -125,6 +125,10 @@ Jobs with a `workdir` run sequentially on the scheduler tick, not in the paralle
You do not need to delete and recreate jobs just to change them.
:::tip Job reference
The `<job_id>` placeholder below (and in [Lifecycle actions](#lifecycle-actions)) also accepts the job's name (case-insensitive) — handy when you remember `morning-digest` but not the hex ID. An exact job ID takes precedence over name matches; if the reference is not an ID and a name matches more than one job, the command refuses and prints the candidate IDs so you can disambiguate.
:::
### Chat
```bash

View file

@ -274,6 +274,7 @@ delegation:
model: "qwen2.5-coder"
base_url: "http://localhost:1234/v1"
api_key: "local-key"
# api_mode: "anthropic_messages" # Optional. Wire protocol override for base_url ("chat_completions", "codex_responses", or "anthropic_messages"). Empty = auto-detect from URL (e.g. /anthropic suffix). Set explicitly for endpoints the heuristic can't classify (Azure AI Foundry, MiniMax, Zhipu GLM, LiteLLM proxies, …).
```
:::tip

View file

@ -12,7 +12,7 @@ Unlike Hermes' built-in OAuth integrations (Google, GitHub Copilot, Codex), Spot
## Setup
### One-shot: `hermes tools`
### One-shot: `hermes tools` or first-run setup
The fastest path. Run:
@ -20,7 +20,9 @@ The fastest path. Run:
hermes tools
```
Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. Hermes drops you straight into the OAuth flow — if you don't have a Spotify app yet, it walks you through creating one inline. Once you finish, the toolset is enabled AND authenticated in one pass.
Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. The same toggle is also available during the first-run `hermes setup` / `hermes setup tools` flow. Spotify stays opt-in, so enabling it there runs the same provider-aware configuration as `hermes tools`.
Hermes drops you straight into the OAuth flow — if you don't have a Spotify app yet, it walks you through creating one inline. Once you finish, the toolset is enabled AND authenticated in one pass.
If you prefer to do the steps separately (or you're re-authing later), use the two-step flow below.
@ -125,6 +127,12 @@ Control and inspect playback, plus fetch recently played history.
| `list` | Every Spotify Connect device visible to your account |
| `transfer` | Move playback to `device_id`. Optional `play: true` starts playback on transfer |
### Home Assistant-managed speakers
If Home Assistant manages speakers that already support Spotify Connect (for example Sonos, Echo, Nest, or other Connect-capable speakers), they appear in `spotify_devices list` automatically whenever Spotify can see them. Hermes does not need a Home Assistant ↔ Spotify bridge for this path — Spotify handles the device routing natively.
Ask Hermes to transfer playback by the speaker's display name (for example, “transfer Spotify to the kitchen speaker”), or call `spotify_devices list` and pass the exact `device_id` to `spotify_devices transfer` when scripting. If the speaker is missing, open the Spotify app or the speaker's Spotify integration once so Spotify registers it as an active Connect target.
#### `spotify_queue`
| Action | Purpose | Premium? |
|--------|---------|----------|

View file

@ -35,6 +35,9 @@ hermes dashboard --host 0.0.0.0
# Start without opening browser
hermes dashboard --no-open
# Enable the in-browser Chat tab
hermes dashboard --tui
```
## Prerequisites
@ -49,6 +52,8 @@ The `web` extra pulls in FastAPI/Uvicorn; `pty` pulls in `ptyprocess` (POSIX) or
When you run `hermes dashboard` without the dependencies, it will tell you what to install. If the frontend hasn't been built yet and `npm` is available, it builds automatically on first launch.
The Chat tab is intentionally off for a plain `hermes dashboard` launch. Start the dashboard with `hermes dashboard --tui` or set `HERMES_DASHBOARD_TUI=1` when you want the embedded browser chat pane.
## Pages
### Status

View file

@ -294,6 +294,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede
| `DISCORD_ALLOW_MENTION_USERS` | No | `true` | When `true` (default), the bot can ping individual users by ID. |
| `DISCORD_ALLOW_MENTION_REPLIED_USER` | No | `true` | When `true` (default), replying to a message pings the original author. |
| `DISCORD_PROXY` | No | — | Proxy URL for Discord connections (HTTP, WebSocket, REST). Overrides `HTTPS_PROXY`/`ALL_PROXY`. Supports `http://`, `https://`, and `socks5://` schemes. |
| `DISCORD_ALLOW_ANY_ATTACHMENT` | No | `false` | When `true`, the bot accepts attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached to disk and surfaced to the agent as a local path with `application/octet-stream` MIME so it can inspect them with `terminal` / `read_file` / `ffprobe` / etc. |
| `DISCORD_MAX_ATTACHMENT_BYTES` | No | `33554432` | Maximum bytes per attachment the gateway will download and cache. Default 32 MiB. Set to `0` for no cap (attachments are held in memory while being written, so unlimited carries a real memory cost). |
| `HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS` | No | `0.6` | Grace window the adapter waits before flushing a queued text chunk. Useful for smoothing streamed output. |
| `HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS` | No | `2.0` | Delay between split chunks when a single message exceeds Discord's length limit. |
@ -613,6 +615,25 @@ The Discord adapter supports native file uploads for every common media type via
Discord's per-upload size limit depends on the server's boost tier (25 MB free, up to 500 MB). If Hermes gets an HTTP 413, the adapter falls back to a link pointing at the local cache path rather than failing silently.
## Receiving Arbitrary File Types
By default the bot caches uploads that match a built-in allowlist — images, audio, video, PDF, text/markdown/csv/log, JSON/XML/YAML/TOML, zip, docx/xlsx/pptx. Anything else (a `.wav`, a `.bin`, a custom-extension dump) gets logged as `Unsupported document type` and dropped before the agent sees it.
To accept arbitrary file types, enable `discord.allow_any_attachment`:
```yaml
discord:
allow_any_attachment: true
# Optional — raise/disable the per-file size cap. Default is 32 MiB.
# The whole file is held in memory while being cached, so unlimited
# uploads carry a real memory cost.
max_attachment_bytes: 33554432 # bytes; 0 = unlimited
```
When the flag is on, any uploaded file is downloaded, cached under `~/.hermes/cache/documents/`, and surfaced to the agent as a `DOCUMENT`-typed message event with `application/octet-stream` MIME. The agent receives a context note pointing at the local path (auto-translated for Docker/Modal sandboxed terminals via `to_agent_visible_cache_path`) and can inspect the file with `terminal` (`ffprobe`, `unzip`, `file`, `strings`, etc.) or `read_file`. The file body is **not** inlined into the prompt — only the path — so binary uploads don't blow up the context window.
Known-text formats already in the allowlist (`.txt`, `.md`, `.log`) continue to have their contents auto-injected up to 100 KiB; that behavior is unchanged when the flag is on.
## Home Channel
You can designate a "home channel" where the bot sends proactive messages (such as cron job output, reminders, and notifications). There are two ways to set it:

View file

@ -64,6 +64,11 @@ The `/yolo` command is a **toggle** — each use flips the mode on or off:
YOLO mode is available in both CLI and gateway sessions. Internally, it sets the `HERMES_YOLO_MODE` environment variable which is checked before every command execution.
When YOLO is active, Hermes shows two persistent visual reminders so it's hard to forget that approval prompts are bypassed:
- A red banner line at session start when YOLO is already active: `⚠ YOLO mode — all approval prompts bypassed`. Hidden when YOLO is off so the default banner stays uncluttered.
- A `⚠ YOLO` fragment in the status bar across all width tiers, updated live as you toggle YOLO on or off (rich-text renderer and plain-text fallback).
:::danger
YOLO mode disables **all** dangerous command safety checks for the session — **except** the hardline blocklist (see below). Use only when you fully trust the commands being generated (e.g., well-tested automation scripts in disposable environments).
:::