opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
+2 -52
View File
@@ -151,13 +151,7 @@ def _is_gateway_approval_context() -> bool:
return bool(_get_session_platform())
# Sensitive write targets that should trigger approval even when referenced
# via shell expansions like $HOME or $HERMES_HOME, or by the resolved absolute
# active profile home path such as /home/hermes/.hermes/config.yaml. The
# resolved-absolute form is folded into the ~/.hermes/ patterns at detection
# time by _normalize_command_for_detection() — see the rewrite step there — so
# these static patterns stay free of any import-time path snapshot (which would
# go stale when HERMES_HOME is set after this module is imported, e.g. under the
# hermetic test conftest or any deferred-profile-resolution path).
# via shell expansions like $HOME or $HERMES_HOME.
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH = (
r'(?:~\/\.hermes/|'
@@ -545,49 +539,8 @@ def _normalize_command_for_detection(command: str) -> str:
command = unicodedata.normalize('NFKC', command)
# Strip shell backslash-escapes: r\m → rm. Prevents \-injection bypass.
command = re.sub(r'\\([^\n])', r'\1', command)
# Strip empty-string literals that split tokens: r''m → rm, r"\"m → rm.
# Strip empty-string literals that split tokens: r''m → rm, r""m → rm.
command = re.sub(r"''|\"\"", '', command)
# Fold the resolved absolute active-profile home path into the canonical
# ~/.hermes/ form so the Hermes config/env patterns catch it. In Docker and
# gateway deployments the agent often references the resolved absolute path
# directly (e.g. `sed -i ... /home/hermes/.hermes/config.yaml`) rather than
# ~, $HOME, or $HERMES_HOME. Done at detection time (not via an import-time
# pattern snapshot) so it tracks the live HERMES_HOME even when that is set
# after this module is imported — as the hermetic test conftest does.
command = _rewrite_resolved_hermes_home(command)
return command
def _rewrite_resolved_hermes_home(command: str) -> str:
"""Rewrite the resolved absolute Hermes home prefix to ``~/.hermes/``.
Resolves the active ``HERMES_HOME`` at call time (and its symlink-resolved
form) and replaces an occurrence of ``<home>/`` in *command* with
``~/.hermes/`` so the static ``_HERMES_CONFIG_PATH`` / ``_HERMES_ENV_PATH``
patterns match. No-op when the path can't be resolved or doesn't appear.
"""
try:
from hermes_constants import get_hermes_home
home = get_hermes_home().expanduser()
candidates = [
str(home).rstrip("/"),
str(home.resolve(strict=False)).rstrip("/"),
]
except Exception:
return command
seen: set[str] = set()
for path in candidates:
if not path or path in seen:
continue
seen.add(path)
# Guard against a degenerate HERMES_HOME (e.g. "/" or "") rewriting
# unrelated paths: require an absolute path with at least one non-root
# component. The active profile home is always a real directory like
# /home/hermes/.hermes or a per-test tempdir, never a bare root.
normalized = path.rstrip("/")
if not normalized.startswith("/") or normalized.count("/") < 2:
continue
command = command.replace(normalized + "/", "~/.hermes/")
return command
@@ -1428,9 +1381,6 @@ def check_all_command_guards(command: str, env_type: str,
"pattern_key": primary_key,
"pattern_keys": all_keys,
"description": combined_desc,
# Mirror the CLI's allow_permanent gate: a tirith warning downgrades
# "always" to session scope below, so the UI must not offer it.
"allow_permanent": not has_tirith,
}
decision = _await_gateway_decision(
session_key, notify_cb, approval_data, surface="gateway"
-325
View File
@@ -1,325 +0,0 @@
"""Blueprints: shareable plain-language automations layered on skills + cron.
A "blueprint" is NOT a new object type. It is an ordinary skill (a SKILL.md the
agent loads) that additionally declares an automation schedule in its
frontmatter:
metadata:
hermes:
blueprint:
schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable
deliver: origin # optional (default "origin")
prompt: "..." # optional task instruction for the run
no_agent: false # optional
Because a blueprint is just a skill, it flows through the ENTIRE existing
skills-hub pipeline for free — search, inspect, quarantine, security scan,
install, lock-file provenance, audit log, taps, the centralized index, and
`hermes skills publish` for sharing. No new source type, no new store, no new
transport. This module is the thin bridge between that skill metadata and the
existing cron `create_job()` API:
* ``parse_blueprint(skill_md_text)`` -> BlueprintSpec | None
* ``blueprint_spec_for_installed(name)`` -> BlueprintSpec | None
* ``create_blueprint_job(spec, ...)`` -> the created cron job dict
* ``export_blueprint(job, body)`` -> a shareable SKILL.md string
The dev guide's "Extend, Don't Duplicate" rule is the whole design: the blueprint
is a skill, the schedule is a cron job, sharing is the existing publish/tap/
index path.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
__all__ = [
"BlueprintSpec",
"parse_blueprint",
"blueprint_spec_for_installed",
"blueprint_to_job_spec",
"create_blueprint_job",
"register_blueprint_suggestion",
"export_blueprint",
"BlueprintError",
]
class BlueprintError(ValueError):
"""Raised when a blueprint block is present but malformed."""
@dataclass
class BlueprintSpec:
"""Parsed ``metadata.hermes.blueprint`` automation spec for a skill."""
skill_name: str
schedule: str
deliver: str = "origin"
prompt: Optional[str] = None
no_agent: bool = False
model: Optional[str] = None
provider: Optional[str] = None
enabled_toolsets: Optional[List[str]] = None
raw: Dict[str, Any] = field(default_factory=dict)
def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
"""Return the parsed YAML frontmatter mapping, or None if absent/invalid."""
if not isinstance(text, str):
return None
stripped = text.lstrip()
if not stripped.startswith("---"):
return None
# Find the closing fence after the opening one.
after_open = stripped[3:]
end = after_open.find("\n---")
if end == -1:
return None
fm_text = after_open[:end]
try:
import yaml
data = yaml.safe_load(fm_text)
except Exception as e: # pragma: no cover - malformed YAML
logger.debug("blueprint: frontmatter YAML parse failed: %s", e)
return None
return data if isinstance(data, dict) else None
def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]:
"""Extract a BlueprintSpec from a SKILL.md string, or None if not a blueprint.
A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing
a non-empty ``schedule``. Raises BlueprintError if the block exists but is
structurally invalid (so a typo surfaces instead of silently no-op'ing).
"""
fm = _split_frontmatter(skill_md_text)
if not fm:
return None
name = str(fm.get("name", "")).strip()
meta = fm.get("metadata")
hermes = meta.get("hermes") if isinstance(meta, dict) else None
blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None
if blueprint is None:
return None
if not isinstance(blueprint, dict):
raise BlueprintError("metadata.hermes.blueprint must be a mapping")
schedule = str(blueprint.get("schedule", "")).strip()
if not schedule:
raise BlueprintError("blueprint.schedule is required and must be non-empty")
deliver = str(blueprint.get("deliver", "origin")).strip() or "origin"
prompt = blueprint.get("prompt")
if prompt is not None:
prompt = str(prompt)
no_agent = bool(blueprint.get("no_agent", False))
model = blueprint.get("model")
provider = blueprint.get("provider")
toolsets = blueprint.get("enabled_toolsets")
if toolsets is not None and not isinstance(toolsets, list):
raise BlueprintError("blueprint.enabled_toolsets must be a list when present")
return BlueprintSpec(
skill_name=name,
schedule=schedule,
deliver=deliver,
prompt=prompt,
no_agent=no_agent,
model=str(model).strip() if model else None,
provider=str(provider).strip() if provider else None,
enabled_toolsets=[str(t) for t in toolsets] if toolsets else None,
raw=blueprint,
)
def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]:
"""Locate an installed skill's SKILL.md and parse its blueprint block.
Searches the standard skills tree for ``<skill_name>/SKILL.md``. Returns
None if the skill isn't found or isn't a blueprint.
"""
try:
from tools.skills_hub import SKILLS_DIR
except Exception: # pragma: no cover - import guard
return None
base = Path(SKILLS_DIR)
# Skills live at skills/<category>/<name>/SKILL.md or skills/<name>/SKILL.md.
candidates = list(base.glob(f"**/{skill_name}/SKILL.md"))
for path in candidates:
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
spec = parse_blueprint(text)
if spec is not None:
# Prefer the frontmatter name, fall back to the directory name.
if not spec.skill_name:
spec.skill_name = skill_name
return spec
return None
def blueprint_to_job_spec(
spec: BlueprintSpec,
*,
name: Optional[str] = None,
) -> Dict[str, Any]:
"""Build the ``cron.jobs.create_job`` kwargs dict for a BlueprintSpec.
This is the single source of truth for translating a blueprint into a job.
Both the direct ``create_blueprint_job`` path and the suggestion path
(``register_blueprint_suggestion``) build on it, so a blueprint scheduled now and
a blueprint accepted from a suggestion produce an identical job.
"""
return {
"prompt": spec.prompt,
"schedule": spec.schedule,
"name": name or f"blueprint:{spec.skill_name}",
"deliver": spec.deliver,
"skills": [spec.skill_name] if spec.skill_name else None,
"model": spec.model,
"provider": spec.provider,
"enabled_toolsets": spec.enabled_toolsets,
"no_agent": spec.no_agent,
}
def create_blueprint_job(
spec: BlueprintSpec,
*,
origin: Optional[Dict[str, Any]] = None,
name: Optional[str] = None,
) -> Dict[str, Any]:
"""Create the cron job described by a BlueprintSpec via the existing cron API.
The blueprint's skill is loaded before the run (cron ``skills=[name]``); the
optional ``prompt`` becomes the task instruction. Delivery, model, and
toolsets carry through. Returns the created job dict.
"""
from cron.jobs import create_job
job_spec = blueprint_to_job_spec(spec, name=name)
if origin is not None:
job_spec["origin"] = origin
return create_job(**job_spec)
def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]:
"""Turn an installed blueprint into a pending Suggested Cron Job.
Blueprints are source ``blueprint`` of the unified suggestion surface: installing
a skill that carries a ``blueprint:`` block does NOT auto-schedule it — it
registers a suggestion the user accepts (or dismisses) like any other.
Returns the suggestion record, or None if it was skipped (already
seen/dismissed, backlog full, etc.).
"""
if not spec.skill_name:
return None
try:
from cron.suggestions import add_suggestion
except Exception: # pragma: no cover - import guard
return None
return add_suggestion(
title=f"Schedule '{spec.skill_name}'",
description=(
f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}"
+ (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "")
+ "."
),
source="blueprint",
job_spec=blueprint_to_job_spec(spec),
dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}",
)
def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional[str] = None) -> str:
"""Render a shareable blueprint SKILL.md from an existing cron job dict.
The inverse of ``create_blueprint_job``: take a cron job a user already built
and emit a SKILL.md (with a ``metadata.hermes.blueprint`` block) they can hand
to ``hermes skills publish`` to share. ``body`` is the plain-language
description / instructions that become the SKILL.md body.
"""
import yaml
name = blueprint_name or job.get("name") or "shared-blueprint"
# Sanitize to a valid skill identifier.
name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower())
name = name.strip("-_") or "shared-blueprint"
schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule"))
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
blueprint_block: Dict[str, Any] = {"schedule": schedule}
deliver = job.get("deliver")
if deliver and deliver != "origin":
blueprint_block["deliver"] = deliver
if job.get("prompt"):
blueprint_block["prompt"] = job["prompt"]
if job.get("no_agent"):
blueprint_block["no_agent"] = True
if job.get("model"):
blueprint_block["model"] = job["model"]
if job.get("provider"):
blueprint_block["provider"] = job["provider"]
if job.get("enabled_toolsets"):
blueprint_block["enabled_toolsets"] = job["enabled_toolsets"]
description = (
(body.strip().splitlines() or ["Shared automation blueprint."])[0][:200]
if body.strip()
else "Shared automation blueprint."
)
frontmatter = {
"name": name,
"description": description,
"version": "1.0.0",
"license": "MIT",
"metadata": {
"hermes": {
"tags": ["blueprint", "automation"],
"blueprint": blueprint_block,
}
},
}
fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip()
body_text = body.strip() or f"# {name}\n\nShared automation blueprint."
return f"---\n{fm_yaml}\n---\n\n{body_text}\n"
def _schedule_to_string(schedule: Any) -> str:
"""Best-effort render of a parsed schedule dict back to a string."""
if isinstance(schedule, str):
return schedule
if isinstance(schedule, dict):
kind = schedule.get("kind")
if kind == "cron" and schedule.get("expr"):
return str(schedule["expr"])
if kind == "interval":
# parse_schedule stores interval periods as "minutes"; tolerate a
# legacy/foreign "seconds" form too.
if schedule.get("minutes"):
mins = int(schedule["minutes"])
if mins % 60 == 0:
return f"every {mins // 60}h"
return f"every {mins}m"
if schedule.get("seconds"):
secs = int(schedule["seconds"])
if secs % 3600 == 0:
return f"every {secs // 3600}h"
if secs % 60 == 0:
return f"every {secs // 60}m"
return f"every {secs}s"
return "0 9 * * *" # safe daily fallback
-2
View File
@@ -307,7 +307,6 @@ def _run_git(
timeout=timeout,
env=env,
cwd=str(normalized_working_dir),
stdin=subprocess.DEVNULL,
)
ok = result.returncode == 0
stdout = result.stdout.strip()
@@ -427,7 +426,6 @@ def _init_store(store: Path, working_dir: str) -> Optional[str]:
["git", "init", "--bare", str(store)],
capture_output=True, text=True,
env=init_env, timeout=_GIT_TIMEOUT,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
return f"Shadow store init failed: {result.stderr.strip()}"
-1
View File
@@ -1618,7 +1618,6 @@ def _is_usable_python(python_path: str) -> bool:
timeout=5,
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
stdin=subprocess.DEVNULL,
)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
+5 -78
View File
@@ -32,12 +32,10 @@ For captures / actions with `capture_after=True`:
from __future__ import annotations
import base64
import json
import logging
import os
import re
import struct
import sys
import threading
from typing import Any, Dict, List, Optional, Tuple
@@ -431,61 +429,6 @@ _DEFAULT_MAX_ELEMENTS = 100
# call passing a very large integer would silently disable the safeguard and
# reintroduce the original unbounded behavior.
_MAX_ALLOWED_MAX_ELEMENTS = 1000
_MIN_PROVIDER_IMAGE_DIMENSION = 8
def _image_dimensions_from_b64(image_b64: str) -> Optional[Tuple[int, int]]:
"""Return (width, height) for common inline screenshot formats.
Some providers reject images below 8x8 before the model sees the tool
result. Inspecting the encoded bytes here lets computer_use fall back to
its AX/SOM text payload instead of sending an unusable placeholder.
"""
if not image_b64:
return None
try:
raw = base64.b64decode(image_b64, validate=False)
except Exception:
return None
# PNG: signature + IHDR width/height.
if raw.startswith(b"\x89PNG\r\n\x1a\n") and len(raw) >= 24:
try:
width, height = struct.unpack(">II", raw[16:24])
return int(width), int(height)
except Exception:
return None
# JPEG: scan for SOF markers that carry dimensions.
if raw.startswith(b"\xff\xd8") and len(raw) > 4:
i = 2
while i + 9 < len(raw):
if raw[i] != 0xFF:
i += 1
continue
marker = raw[i + 1]
i += 2
while marker == 0xFF and i < len(raw):
marker = raw[i]
i += 1
if marker in {0xD8, 0xD9}:
continue
if marker == 0xDA:
break
if i + 2 > len(raw):
break
segment_len = int.from_bytes(raw[i:i + 2], "big")
if segment_len < 2 or i + segment_len > len(raw):
break
if marker in {
0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
} and segment_len >= 7:
height = int.from_bytes(raw[i + 3:i + 5], "big")
width = int.from_bytes(raw[i + 5:i + 7], "big")
return int(width), int(height)
i += segment_len
return None
def _coerce_max_elements(value: Any) -> int:
@@ -514,16 +457,6 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
total_elements = len(cap.elements)
visible_elements = cap.elements[:max_elements]
truncated_elements = max(0, total_elements - len(visible_elements))
image_dimensions = _image_dimensions_from_b64(cap.png_b64 or "") if cap.png_b64 else None
response_width = image_dimensions[0] if image_dimensions else cap.width
response_height = image_dimensions[1] if image_dimensions else cap.height
image_too_small = bool(
image_dimensions
and (
image_dimensions[0] < _MIN_PROVIDER_IMAGE_DIMENSION
or image_dimensions[1] < _MIN_PROVIDER_IMAGE_DIMENSION
)
)
# Index only what's actually surfaced in the response — otherwise the
# human-readable summary references element indices the model cannot
@@ -531,7 +464,7 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
# 40-line index window).
element_index = _format_elements(visible_elements)
summary_lines = [
f"capture mode={cap.mode} {response_width}x{response_height}"
f"capture mode={cap.mode} {cap.width}x{cap.height}"
+ (f" app={cap.app}" if cap.app else "")
+ (f" window={cap.window_title!r}" if cap.window_title else ""),
f"{total_elements} interactable element(s):",
@@ -543,15 +476,9 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
# selected) has a valid value to hand to _route_capture_through_aux_vision.
# The AX path appends the "truncated to N of M" note to summary_lines
# below and rebuilds; the multimodal path keeps this version untouched.
if image_too_small:
summary_lines.append(
f" (screenshot omitted: {image_dimensions[0]}x{image_dimensions[1]} "
f"is below the {_MIN_PROVIDER_IMAGE_DIMENSION}x{_MIN_PROVIDER_IMAGE_DIMENSION} "
"provider minimum)"
)
summary = "\n".join(summary_lines)
if cap.png_b64 and cap.mode != "ax" and not image_too_small:
if cap.png_b64 and cap.mode != "ax":
# Decide whether to hand the screenshot to the auxiliary.vision
# pipeline (text-only result) or keep the multimodal envelope (main
# model handles vision natively). Issue #24015: previously the
@@ -583,7 +510,7 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
"image_url": {"url": f"data:{_mime};base64,{cap.png_b64}"}},
],
"text_summary": summary,
"meta": {"mode": cap.mode, "width": response_width, "height": response_height,
"meta": {"mode": cap.mode, "width": cap.width, "height": cap.height,
"elements": total_elements, "png_bytes": cap.png_bytes_len},
}
# AX-only (or image-missing fallback): text path actually carries the
@@ -596,8 +523,8 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
summary = "\n".join(summary_lines)
payload: Dict[str, Any] = {
"mode": cap.mode,
"width": response_width,
"height": response_height,
"width": cap.width,
"height": cap.height,
"app": cap.app,
"window_title": cap.window_title,
"elements": [_element_to_dict(e) for e in visible_elements],
+20 -15
View File
@@ -326,23 +326,15 @@ def _resolve_model_override(model_obj: Optional[Dict[str, Any]]) -> tuple:
return (None, None)
model_name = (model_obj.get("model") or "").strip() or None
provider_name = (model_obj.get("provider") or "").strip() or None
# Bare "custom" is usually an incomplete spec — the canonical form is
# "custom:<name>" matching a custom_providers entry, and LLMs frequently
# Bare "custom" is an incomplete spec — the canonical form is
# "custom:<name>" matching a custom_providers entry. LLMs frequently
# supply the bare type because the schema does not advertise the
# ":<name>" suffix. It is only a problem when it can't resolve at runtime:
# a user may literally name a ``providers.custom`` (or custom_providers
# "custom") entry, in which case the job should keep ``provider="custom"``
# and run against that endpoint. Only when no such entry exists do we treat
# the bare value as "no provider supplied" and pin the current main
# provider below — otherwise pinning to ``model.provider`` (e.g. codex)
# silently hijacks a job that meant to use the configured custom endpoint.
# ":<name>" suffix, which used to bypass the pinning path below and
# leave the job stored with an unresolvable "custom" provider. Treat
# the bare value as "no provider supplied" so the current main
# provider gets pinned instead.
if provider_name == "custom":
try:
from hermes_cli.runtime_provider import has_named_custom_provider
if not has_named_custom_provider("custom"):
provider_name = None
except Exception:
provider_name = None
provider_name = None
if model_name and not provider_name:
# Pin to the current main provider so the job is stable
try:
@@ -459,6 +451,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
result["enabled_toolsets"] = job["enabled_toolsets"]
if job.get("workdir"):
result["workdir"] = job["workdir"]
if job.get("profile"):
result["profile"] = job["profile"]
return result
@@ -481,6 +475,7 @@ def cronjob(
context_from: Optional[Union[str, List[str]]] = None,
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
profile: Optional[str] = None,
no_agent: Optional[bool] = None,
task_id: str = None,
) -> str:
@@ -547,6 +542,7 @@ def cronjob(
context_from=context_from,
enabled_toolsets=enabled_toolsets or None,
workdir=_normalize_optional_job_value(workdir),
profile=_normalize_optional_job_value(profile),
no_agent=_no_agent,
)
return json.dumps(
@@ -681,6 +677,10 @@ def cronjob(
# Empty string clears the field (restores old behaviour);
# otherwise pass raw — update_job() validates / normalizes.
updates["workdir"] = _normalize_optional_job_value(workdir) or None
if profile is not None:
# Empty string clears the field (restores old behaviour);
# otherwise pass raw — update_job() validates / normalizes.
updates["profile"] = _normalize_optional_job_value(profile) or None
if no_agent is not None:
# Toggling no_agent on/off at update time. If flipping to True,
# we need a script to already exist on the job (or be part of
@@ -834,6 +834,10 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
"type": "string",
"description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated."
},
"profile": {
"type": "string",
"description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile, applies a context-local Hermes home override, loads that profile's config/.env for the run, and bridges HERMES_HOME into subprocesses. Any temporary process-environment changes from profile .env loading are restored after the job exits. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep profile-scoped runtime state isolated."
},
},
"required": ["action"]
}
@@ -888,6 +892,7 @@ registry.register(
context_from=args.get("context_from"),
enabled_toolsets=args.get("enabled_toolsets"),
workdir=args.get("workdir"),
profile=args.get("profile"),
no_agent=args.get("no_agent"),
task_id=kw.get("task_id"),
))(),
-1
View File
@@ -65,7 +65,6 @@ def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
text=True,
timeout=timeout,
check=False,
stdin=subprocess.DEVNULL,
)
return result.returncode, (result.stdout or "").strip(), (result.stderr or "").strip()
except FileNotFoundError:
+1 -17
View File
@@ -177,7 +177,6 @@ def reap_orphan_containers(
listing = subprocess.run(
[docker, "ps", "-a", *filters, "--format", "{{.ID}}"],
capture_output=True, text=True, timeout=15, check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker ps failed: %s", e)
@@ -211,7 +210,6 @@ def reap_orphan_containers(
result = subprocess.run(
[docker, "rm", "-f", cid],
capture_output=True, text=True, timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
removed += 1
@@ -241,7 +239,6 @@ def _container_finished_at(docker_exe: str, container_id: str):
result = subprocess.run(
[docker_exe, "inspect", "--format", "{{.State.FinishedAt}}", container_id],
capture_output=True, text=True, timeout=10, check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker inspect %s failed: %s", container_id[:12], e)
@@ -384,7 +381,6 @@ def _image_uses_init_entrypoint(docker_exe: str, image: str) -> bool:
capture_output=True,
text=True,
timeout=15,
stdin=subprocess.DEVNULL,
)
except (subprocess.SubprocessError, OSError) as e:
logger.debug("Docker: could not inspect entrypoint for %s: %s", image, e)
@@ -457,7 +453,6 @@ def _ensure_docker_available() -> None:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.error(
@@ -838,7 +833,6 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=30,
check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(
@@ -877,7 +871,6 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=120, # image pull may take a while
check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Docker may create the container object before `docker run`
@@ -894,7 +887,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[self._docker_exe, "rm", "-f", container_name],
capture_output=True, timeout=10,
stdin=subprocess.DEVNULL,
)
raise
self._container_id = result.stdout.strip()
@@ -1005,7 +997,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[self._docker_exe, "start", cid],
capture_output=True, text=True, timeout=30, check=True,
stdin=subprocess.DEVNULL,
)
self._container_id = cid
logger.info("Recovery: restarted container %s", cid[:12])
@@ -1036,7 +1027,6 @@ class DockerEnvironment(BaseEnvironment):
]
result = subprocess.run(
run_cmd, capture_output=True, text=True, timeout=120, check=True,
stdin=subprocess.DEVNULL,
)
self._container_id = result.stdout.strip()
self._container_name = new_name
@@ -1091,7 +1081,6 @@ class DockerEnvironment(BaseEnvironment):
result = subprocess.run(
[docker, "info", "--format", "{{.Driver}}"],
capture_output=True, text=True, timeout=10,
stdin=subprocess.DEVNULL,
)
driver = result.stdout.strip().lower()
if driver != "overlay2":
@@ -1102,15 +1091,13 @@ class DockerEnvironment(BaseEnvironment):
probe = subprocess.run(
[docker, "create", "--storage-opt", "size=1m", "hello-world"],
capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL,
)
if probe.returncode == 0:
# Clean up the created container
container_id = probe.stdout.strip()
if container_id:
subprocess.run([docker, "rm", container_id],
capture_output=True, timeout=5,
stdin=subprocess.DEVNULL)
capture_output=True, timeout=5)
_storage_opt_ok = True
else:
_storage_opt_ok = False
@@ -1145,7 +1132,6 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=10,
check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("docker ps probe failed: %s — will start a fresh container", e)
@@ -1262,7 +1248,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[docker_exe, "stop", "-t", "10", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker stop %s timed out / failed: %s", log_id, e)
@@ -1271,7 +1256,6 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[docker_exe, "rm", "-f", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker rm -f %s failed: %s", log_id, e)
+11 -69
View File
@@ -300,72 +300,6 @@ _SANE_PATH = (
)
def _append_missing_sane_path_entries(existing_path: str) -> str:
"""Return a normalised POSIX PATH with missing sane entries appended.
On POSIX the caller-supplied PATH is rewritten (not merely appended to):
empty entries and duplicate entries are dropped, preserving
first-occurrence order, then each missing ``_SANE_PATH`` entry is appended
once at the end so existing entries keep their precedence.
Two intentional normalisations beyond the bare "add Homebrew dirs" fix:
- **Empty entries are stripped.** A leading/trailing/double ``:`` encodes
an empty PATH element, which POSIX shells interpret as the current
working directory — a mild foot-gun in a default terminal environment.
We drop these rather than carry them through.
- **Duplicates are collapsed** (first occurrence wins), so a caller PATH
that already contains repeats is not propagated verbatim.
For a well-formed PATH (no empties, no duplicates) the leading segment is
byte-identical to the input and ordering is preserved; only the missing
sane entries are appended. On Windows this is a no-op passthrough (the
separator is ``;`` and the native PATH must not be touched).
"""
if _IS_WINDOWS:
return existing_path
sane_entries = [entry for entry in _SANE_PATH.split(":") if entry]
if not existing_path:
return ":".join(sane_entries)
# De-duplicate the caller PATH (first occurrence wins) and drop empty
# entries before merging in the sane fallbacks.
seen: set[str] = set()
ordered_entries: list[str] = []
for entry in existing_path.split(":"):
if not entry or entry in seen:
continue
seen.add(entry)
ordered_entries.append(entry)
# _SANE_PATH is a static, duplicate-free constant, so a membership check
# against the caller entries is sufficient — no need to track `seen` here.
for entry in sane_entries:
if entry not in seen:
ordered_entries.append(entry)
return ":".join(ordered_entries)
def _path_env_key(run_env: dict) -> str | None:
"""Return the PATH env key to update without altering Windows casing.
Note: this is deliberately a *second* Windows guard, distinct from the
early-return in ``_append_missing_sane_path_entries``. Its job is to pick
the correctly-cased key (``Path`` vs ``PATH``) so completion writes back to
the key the caller already used; the helper's guard makes that helper safe
to call standalone (it is, e.g. in the Windows unit tests). Both are
intentional.
"""
if not _IS_WINDOWS:
return "PATH"
for key in run_env:
if key.upper() == "PATH":
return key
return None
def _make_run_env(env: dict) -> dict:
"""Build a run environment with a sane PATH and provider-var stripping."""
try:
@@ -381,9 +315,17 @@ def _make_run_env(env: dict) -> dict:
run_env[real_key] = v
elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(k):
run_env[k] = v
path_key = _path_env_key(run_env)
if path_key is not None:
run_env[path_key] = _append_missing_sane_path_entries(run_env.get(path_key, ""))
existing_path = run_env.get("PATH", "")
# The "/usr/bin not already present → inject sane POSIX path" heuristic
# only makes sense on POSIX. On Windows the PATH separator is ";"
# (the split(":") above turns a full Windows PATH into a single
# unrecognisable chunk, which then triggers prepending POSIX paths
# to a Windows PATH — completely wrong). Skip the injection entirely
# on Windows; the native PATH already points at whatever shell
# Hermes is driving via _find_bash (Git Bash), and Git Bash itself
# prepends its MSYS2 /usr/bin equivalent via the shell-init files.
if not _IS_WINDOWS and "/usr/bin" not in existing_path.split(":"):
run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH
_inject_context_hermes_home(run_env)
+1 -4
View File
@@ -46,7 +46,6 @@ def _ensure_singularity_available() -> str:
try:
result = subprocess.run(
[exe, "version"], capture_output=True, text=True, timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
raise RuntimeError(
@@ -137,7 +136,6 @@ def _get_or_build_sif(image: str, executable: str = "apptainer") -> str:
result = subprocess.run(
[executable, "build", str(sif_path), image],
capture_output=True, text=True, timeout=600, env=env,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.warning("SIF build failed, falling back to docker:// URL")
@@ -220,7 +218,7 @@ class SingularityEnvironment(BaseEnvironment):
cmd.extend([str(self.image), self.instance_id])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"Failed to start instance: {result.stderr}")
self._instance_started = True
@@ -252,7 +250,6 @@ class SingularityEnvironment(BaseEnvironment):
subprocess.run(
[self.executable, "instance", "stop", self.instance_id],
capture_output=True, text=True, timeout=30,
stdin=subprocess.DEVNULL,
)
logger.info("Singularity instance %s stopped", self.instance_id)
except Exception as e:
+1 -5
View File
@@ -90,7 +90,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
# ─── Web search backends ───────────────────────────────────────────────
"search.exa": ("exa-py==2.10.2",),
"search.firecrawl": ("firecrawl-py==4.17.0",),
"search.parallel": ("parallel-web==0.6.0",),
"search.parallel": ("parallel-web==0.4.2",),
# ─── TTS providers ─────────────────────────────────────────────────────
# Pinned to exact versions to match pyproject.toml's no-ranges policy
@@ -365,7 +365,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
r = subprocess.run(
[uv_bin, "pip", "install", *specs],
capture_output=True, text=True, timeout=timeout, env=uv_env,
stdin=subprocess.DEVNULL,
)
if r.returncode == 0:
return _InstallResult(True, r.stdout or "", r.stderr or "")
@@ -379,7 +378,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL,
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
@@ -388,7 +386,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
return _InstallResult(False, "",
@@ -398,7 +395,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
r = subprocess.run(
pip_cmd + ["install", *specs],
capture_output=True, text=True, timeout=timeout,
stdin=subprocess.DEVNULL,
)
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
except subprocess.TimeoutExpired as e:
+14 -195
View File
@@ -90,7 +90,7 @@ import sys
import threading
import time
from datetime import datetime
from typing import Any, Coroutine, Dict, List, Optional
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
@@ -268,38 +268,6 @@ _SAFE_ENV_KEYS = frozenset({
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
})
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
# Windows process/location vars. These are needed by launcher-style tools
# such as Docker Desktop's MCP plugin discovery, and do not carry secrets.
"ALLUSERSPROFILE",
"APPDATA",
"COMMONPROGRAMFILES",
"COMMONPROGRAMFILES(X86)",
"COMMONPROGRAMW6432",
"COMPUTERNAME",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"NUMBER_OF_PROCESSORS",
"OS",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PUBLIC",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERNAME",
"USERPROFILE",
"WINDIR",
})
# Regex for credential patterns to strip from error messages
_CREDENTIAL_PATTERN = re.compile(
r"(?:"
@@ -337,11 +305,7 @@ def _build_safe_env(user_env: Optional[dict]) -> dict:
"""
env = {}
for key, value in os.environ.items():
if (
key in _SAFE_ENV_KEYS
or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE
or key.startswith("XDG_")
):
if key in _SAFE_ENV_KEYS or key.startswith("XDG_"):
env[key] = value
if user_env:
env.update(user_env)
@@ -1202,26 +1166,6 @@ class MCPServerTask:
"""Check if this server uses HTTP transport."""
return "url" in self._config
def _advertises_tools(self) -> bool:
"""Whether the server advertises the ``tools`` capability.
Per the MCP spec, ``InitializeResult.capabilities.tools`` is non-None
iff the server implements the ``tools/*`` request family. Prompt-only
or resource-only servers omit it, and calling ``tools/list`` against
them raises ``McpError(-32601 Method not found)`` which previously
killed the connection during discovery and made every keepalive fail.
(Ported from anomalyco/opencode#31271.)
Returns True when no capability info was captured (legacy fallback:
preserve the old always-call-list_tools behavior rather than regress
any server that was working before this gate).
"""
init_result = self.initialize_result
caps = getattr(init_result, "capabilities", None) if init_result is not None else None
if caps is None:
return True
return getattr(caps, "tools", None) is not None
# ----- Dynamic tool discovery (notifications/tools/list_changed) -----
async def _refresh_tools_task(self):
@@ -1293,12 +1237,6 @@ class MCPServerTask:
"""
from tools.registry import registry
if not self._advertises_tools():
# A server that doesn't implement tools/* should never send
# tools/list_changed, but guard anyway — calling tools/list
# would raise McpError(-32601).
return
async with self._refresh_lock:
# Capture old tool names for change diff
old_tool_names = set(self._registered_tool_names)
@@ -1386,22 +1324,12 @@ class MCPServerTask:
# Timeout — no lifecycle event fired. Send a keepalive
# to exercise the connection and detect stale sockets.
# Prompt-only / resource-only servers don't implement
# ``tools/list`` (McpError -32601), so use the universal
# ``ping`` request for them instead — otherwise every
# keepalive cycle would trigger a spurious reconnect.
if self.session:
try:
if self._advertises_tools():
await asyncio.wait_for(
self.session.list_tools(),
timeout=30.0,
)
else:
await asyncio.wait_for(
self.session.send_ping(),
timeout=30.0,
)
await asyncio.wait_for(
self.session.list_tools(),
timeout=30.0,
)
except Exception as exc:
logger.warning(
"MCP server '%s' keepalive failed, "
@@ -1814,25 +1742,9 @@ class MCPServerTask:
)
async def _discover_tools(self):
"""Discover tools from the connected session.
Capability-gated: prompt-only / resource-only MCP servers don't
implement ``tools/list``, and calling it raises ``McpError(-32601)``,
which previously aborted the connection those servers could never
stay connected for their prompts/resources. Skip the call when the
server doesn't advertise the ``tools`` capability.
(Ported from anomalyco/opencode#31271.)
"""
"""Discover tools from the connected session."""
if self.session is None:
return
if not self._advertises_tools():
logger.info(
"MCP server '%s': does not advertise 'tools' capability — "
"skipping tools/list (prompts/resources remain available)",
self.name,
)
self._tools = []
return
async with self._rpc_lock:
tools_result = await self.session.list_tools()
self._tools = (
@@ -2074,8 +1986,6 @@ class MCPServerTask:
# ---------------------------------------------------------------------------
_servers: Dict[str, MCPServerTask] = {}
_server_connecting: set[str] = set()
_server_connect_errors: Dict[str, str] = {}
# Circuit breaker: consecutive error counts per server. After
# _CIRCUIT_BREAKER_THRESHOLD consecutive failures, the handler returns
@@ -2462,8 +2372,8 @@ _mcp_tool_server_names: Dict[str, str] = {}
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
_mcp_thread: Optional[threading.Thread] = None
# Protects _mcp_loop, _mcp_thread, _servers, MCP connection status maps,
# _parallel_safe_servers, _mcp_tool_server_names, and _stdio_pids.
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers,
# _mcp_tool_server_names, and _stdio_pids.
_lock = threading.Lock()
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
@@ -2550,37 +2460,6 @@ def _ensure_mcp_loop():
_mcp_thread.start()
def _wrap_with_home_override(coro: "Coroutine") -> "Coroutine":
"""Carry the caller's context-local HERMES_HOME override into ``coro``.
Returns ``coro`` unchanged when no override is active. Otherwise wraps
it so the override is set inside the coroutine's own (task-local)
context on the MCP loop and reset when it completes concurrent calls
carrying different scopes don't interfere.
"""
try:
from hermes_constants import (
get_hermes_home_override,
reset_hermes_home_override,
set_hermes_home_override,
)
home_override = get_hermes_home_override()
except Exception:
return coro
if not home_override:
return coro
async def _scoped():
token = set_hermes_home_override(home_override)
try:
return await coro
finally:
reset_hermes_home_override(token)
return _scoped()
def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
"""Schedule a coroutine on the MCP event loop and block until done.
@@ -2603,19 +2482,6 @@ def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
raise RuntimeError("MCP event loop is not running")
coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
# Propagate the context-local HERMES_HOME override onto the MCP loop.
# Tasks scheduled via run_coroutine_threadsafe are created INSIDE the
# loop thread, so they copy the loop thread's context — not the
# scheduling thread's. A per-request profile scope (the dashboard's
# ?profile= endpoints, e.g. the MCP "Test server" probe) would silently
# vanish here: OAuth token stores and any other get_hermes_home()
# resolution inside the coroutine would read the process home instead
# of the selected profile's. Re-establish the override inside the
# task's own context (task-local — concurrent calls carrying different
# scopes don't interfere). No-op when no override is active.
coro = _wrap_with_home_override(coro)
future = safe_schedule_threadsafe(
coro, loop,
logger=logger,
@@ -3607,8 +3473,6 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
timeout=connect_timeout,
)
with _lock:
_server_connecting.discard(name)
_server_connect_errors.pop(name, None)
_servers[name] = server
registered_names = _register_server_tools(name, server, config)
@@ -3655,9 +3519,6 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
for k, v in servers.items()
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
}
_server_connecting.update(new_servers)
for srv_name in new_servers:
_server_connect_errors.pop(srv_name, None)
# Track which servers opt-in to parallel tool calls (idempotent).
for srv_name, srv_cfg in servers.items():
if _parse_boolish(srv_cfg.get("supports_parallel_tool_calls", False), default=False):
@@ -3685,20 +3546,12 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
for name, result in zip(server_names, results):
if isinstance(result, BaseException):
command = new_servers.get(name, {}).get("command")
message = _format_connect_error(result)
with _lock:
_server_connecting.discard(name)
_server_connect_errors[name] = message
logger.warning(
"Failed to connect to MCP server '%s'%s: %s",
name,
f" (command={command})" if command else "",
message,
_format_connect_error(result),
)
else:
with _lock:
_server_connecting.discard(name)
_server_connect_errors.pop(name, None)
# Per-server timeouts are handled inside _discover_and_register_server.
# The outer timeout is generous: 120s total for parallel discovery.
@@ -3803,10 +3656,8 @@ def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
def get_mcp_status() -> List[dict]:
"""Return status of all configured MCP servers for banner display.
Returns a list of dicts with keys: name, transport, tools, connected,
disabled, and status. Includes connected servers, disabled servers,
in-flight connection attempts, recorded failures, and servers that are
configured but have not been started in this process yet.
Returns a list of dicts with keys: name, transport, tools, connected.
Includes both successfully connected servers and configured-but-failed ones.
"""
result: List[dict] = []
@@ -3817,8 +3668,6 @@ def get_mcp_status() -> List[dict]:
with _lock:
active_servers = dict(_servers)
connecting = set(_server_connecting)
connect_errors = dict(_server_connect_errors)
for name, cfg in configured.items():
transport = cfg.get("transport", "http") if "url" in cfg else "stdio"
@@ -3831,12 +3680,11 @@ def get_mcp_status() -> List[dict]:
"tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools),
"connected": True,
"disabled": False,
"status": "connected",
}
if server._sampling:
entry["sampling"] = dict(server._sampling.metrics)
result.append(entry)
elif not enabled:
else:
# A server with enabled: false is intentionally not connected — it is
# disabled, not failed. Surface that distinction so consumers (banner,
# TUI) can render "disabled" rather than an alarming "failed".
@@ -3845,36 +3693,7 @@ def get_mcp_status() -> List[dict]:
"transport": transport,
"tools": 0,
"connected": False,
"disabled": True,
"status": "disabled",
})
elif name in connecting:
result.append({
"name": name,
"transport": transport,
"tools": 0,
"connected": False,
"disabled": False,
"status": "connecting",
})
elif name in connect_errors:
result.append({
"name": name,
"transport": transport,
"tools": 0,
"connected": False,
"disabled": False,
"status": "failed",
"error": connect_errors[name],
})
else:
result.append({
"name": name,
"transport": transport,
"tools": 0,
"connected": False,
"disabled": False,
"status": "configured",
"disabled": not enabled,
})
return result
+9 -90
View File
@@ -606,63 +606,6 @@ class MemoryStore:
raise RuntimeError(f"Failed to write memory file {path}: {e}")
def _apply_write_gate(action: str, target: str, content: Optional[str],
old_text: Optional[str]) -> Optional[str]:
"""Evaluate the memory write gate. Returns a JSON tool-result string when
the write should NOT proceed normally (blocked or staged), or None when the
caller should perform the real write.
Only the mutating actions (add/replace/remove) are gated.
"""
if action not in {"add", "replace", "remove"}:
return None
try:
from tools import write_approval as wa
except Exception:
# If the gate module can't load, fail open (current behaviour) rather
# than blocking all memory writes.
return None
# Build a small inline summary/detail for the foreground approval prompt.
label = "user profile" if target == "user" else "memory"
if action == "add":
summary = f"add to {label}"
detail = content or ""
elif action == "replace":
summary = f"replace in {label}"
detail = f"old: {old_text}\nnew: {content}"
else: # remove
summary = f"remove from {label}"
detail = old_text or ""
decision = wa.evaluate_gate(wa.MEMORY, inline_summary=summary, inline_detail=detail)
if decision.allow:
return None
if decision.blocked:
return tool_error(decision.message, success=False)
# stage
payload = {
"action": action,
"target": target,
"content": content,
"old_text": old_text,
}
record = wa.stage_write(
wa.MEMORY, payload,
summary=f"{summary}: {detail[:120]}",
origin=wa.current_origin(),
)
return json.dumps(
{"success": True, "staged": True, "pending_id": record["id"],
"message": decision.message},
ensure_ascii=False,
)
def memory_tool(
action: str,
target: str = "memory",
@@ -681,29 +624,21 @@ def memory_tool(
if target not in {"memory", "user"}:
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
# Validate required params BEFORE the gate so an invalid write is rejected
# immediately instead of being staged and only failing at approve time.
if action == "add" and not content:
return tool_error("Content is required for 'add' action.", success=False)
if action == "replace" and (not old_text or not content):
missing = "old_text" if not old_text else "content"
return tool_error(f"{missing} is required for 'replace' action.", success=False)
if action == "remove" and not old_text:
return tool_error("old_text is required for 'remove' action.", success=False)
# Approval gate: when on, stages the write (background/gateway) or prompts
# inline (interactive CLI); when off (default) passes straight through.
gate_result = _apply_write_gate(action, target, content, old_text)
if gate_result is not None:
return gate_result
if action == "add":
if not content:
return tool_error("Content is required for 'add' action.", success=False)
result = store.add(target, content)
elif action == "replace":
if not old_text:
return tool_error("old_text is required for 'replace' action.", success=False)
if not content:
return tool_error("content is required for 'replace' action.", success=False)
result = store.replace(target, old_text, content)
elif action == "remove":
if not old_text:
return tool_error("old_text is required for 'remove' action.", success=False)
result = store.remove(target, old_text)
else:
@@ -717,23 +652,7 @@ def check_memory_requirements() -> bool:
return True
def apply_memory_pending(payload: Dict[str, Any], store: "MemoryStore") -> Dict[str, Any]:
"""Replay a staged memory write directly against the store, bypassing the
write gate. Called by the /memory approve handler.
Returns the store's result dict.
"""
action = payload.get("action")
target = payload.get("target", "memory")
content = payload.get("content") or ""
old_text = payload.get("old_text") or ""
if action == "add":
return store.add(target, content)
if action == "replace":
return store.replace(target, old_text, content)
if action == "remove":
return store.remove(target, old_text)
return {"success": False, "error": f"Unknown staged action '{action}'."}
# =============================================================================
# OpenAI Function-Calling Schema
# =============================================================================
+2 -7
View File
@@ -472,7 +472,6 @@ class ProcessRegistry:
text=True,
timeout=10,
creationflags=windows_hide_flags(),
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
try:
@@ -1208,14 +1207,10 @@ class ProcessRegistry:
if session.exited:
return {"status": "already_exited", "error": "Process has already finished"}
# PTY mode -- write through pty handle.
# PTY mode -- write through pty handle (expects bytes)
if hasattr(session, '_pty') and session._pty:
try:
# pywinpty expects str on Windows; ptyprocess expects bytes on POSIX.
if _IS_WINDOWS:
pty_data = data.decode("utf-8") if isinstance(data, bytes) else str(data)
else:
pty_data = data.encode("utf-8") if isinstance(data, str) else data
pty_data = data.encode("utf-8") if isinstance(data, str) else data
session._pty.write(pty_data)
return {"status": "ok", "bytes_written": len(data)}
except Exception as e:
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env python3
"""Read the in-app terminal pane in the Hermes desktop GUI.
The embedded terminal's buffer lives in the desktop renderer (xterm.js), so this
tool round-trips through the gateway's blocking-prompt bridge — the same one
`clarify` uses: tui_gateway emits ``terminal.read.request``, the renderer answers
with ``terminal.read.respond``. This module is just schema + a thin dispatcher
over the platform-injected callback.
"""
import json
import os
from typing import Callable, Optional
from tools.registry import registry, tool_error
def read_terminal_tool(
start_line: Optional[int] = None,
count: Optional[int] = None,
callback: Optional[Callable] = None,
) -> str:
"""Return the in-app terminal's contents (+ line metadata) as a JSON string."""
if callback is None:
return tool_error("read_terminal is only available in the Hermes desktop app.")
try:
window = {
key: max(floor, int(val))
for key, val, floor in (("start", start_line, 0), ("count", count, 1))
if val is not None
}
except (TypeError, ValueError):
return tool_error("start_line and count must be integers.")
try:
raw = callback(**window)
except Exception as exc:
return tool_error(f"Failed to read terminal: {exc}")
if not raw:
return tool_error("No in-app terminal is open, or the read timed out.")
# Desktop answers with a JSON object; pass it through, else wrap the raw text.
try:
return json.dumps(json.loads(raw), ensure_ascii=False)
except (TypeError, ValueError):
return json.dumps({"text": str(raw)}, ensure_ascii=False)
def check_read_terminal_requirements() -> bool:
"""Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns."""
return (os.getenv("HERMES_DESKTOP") or "").strip().lower() in ("1", "true", "yes")
READ_TERMINAL_SCHEMA = {
"name": "read_terminal",
"description": (
"Read what's currently shown in the in-app terminal pane of the Hermes "
"desktop GUI (the embedded shell beside this chat). Call with no arguments "
"to get the visible screen plus the total line count (`total_lines`). To "
"page through scrollback, pass `start_line` (0 = oldest line) and `count`; "
"valid lines are [0, total_lines). Returns JSON: "
"{total_lines, start, end, viewport_rows, cursor_row, text}."
),
"parameters": {
"type": "object",
"properties": {
"start_line": {
"type": "integer",
"description": "0-indexed first line (0 = oldest). Omit for the visible screen.",
},
"count": {
"type": "integer",
"description": "Lines to read from start_line. Defaults to the visible row count.",
},
},
},
}
registry.register(
name="read_terminal",
toolset="terminal",
schema=READ_TERMINAL_SCHEMA,
handler=lambda args, **kw: read_terminal_tool(
start_line=args.get("start_line"),
count=args.get("count"),
callback=kw.get("callback"),
),
check_fn=check_read_terminal_requirements,
emoji="🖥️",
)
+1 -1
View File
@@ -38,7 +38,7 @@ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
# below and falls through to channel-name resolution, which has no way to
# resolve a raw phone number. Keeping the '+' preserves the E.164 form that
# downstream adapters (signal, etc.) expect.
_PHONE_PLATFORMS = frozenset({"photon", "signal", "sms", "whatsapp"})
_PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"})
_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
# Email addresses — a valid email like "user@domain.com" should be treated as
# an explicit target for the email platform, not fall through to channel-name
-82
View File
@@ -822,75 +822,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
# Main entry point
# =============================================================================
# ContextVar bypass: set while replaying an already-approved staged skill write
# so skill_manage() does not re-gate (and re-stage) it.
import contextvars as _ctxvars
_skill_gate_bypass: "_ctxvars.ContextVar[bool]" = _ctxvars.ContextVar(
"skill_gate_bypass", default=False
)
def _apply_skill_write_gate(action, name, **payload_kwargs):
"""Evaluate the skill write gate. Returns a JSON tool-result string when the
write should NOT proceed (blocked or staged), or None to perform the real
write. Bypassed during approved-pending replay.
"""
if action not in {"create", "edit", "patch", "delete", "write_file", "remove_file"}:
return None
if _skill_gate_bypass.get():
return None
try:
from tools import write_approval as wa
except Exception:
return None # fail open
decision = wa.evaluate_gate(wa.SKILLS)
if decision.allow:
return None
if decision.blocked:
return tool_error(decision.message, success=False)
# stage — record the full skill_manage kwargs so approval can replay it.
payload = {"action": action, "name": name}
payload.update({k: v for k, v in payload_kwargs.items() if v is not None})
gist = wa.skill_gist(
action, name,
content=payload_kwargs.get("content") or "",
file_path=payload_kwargs.get("file_path") or "",
old_string=payload_kwargs.get("old_string") or "",
new_string=payload_kwargs.get("new_string") or "",
)
record = wa.stage_write(wa.SKILLS, payload, summary=gist, origin=wa.current_origin())
return json.dumps(
{"success": True, "staged": True, "pending_id": record["id"],
"gist": gist, "message": decision.message},
ensure_ascii=False,
)
def apply_skill_pending(payload: Dict[str, Any]) -> str:
"""Replay a staged skill write, bypassing the gate. Returns the tool result
JSON string. Called by the /skills approve handler.
"""
token = _skill_gate_bypass.set(True)
try:
return skill_manage(
action=payload.get("action", ""),
name=payload.get("name", ""),
content=payload.get("content"),
category=payload.get("category"),
file_path=payload.get("file_path"),
file_content=payload.get("file_content"),
old_string=payload.get("old_string"),
new_string=payload.get("new_string"),
replace_all=payload.get("replace_all", False),
absorbed_into=payload.get("absorbed_into"),
)
finally:
_skill_gate_bypass.reset(token)
def skill_manage(
action: str,
name: str,
@@ -908,19 +839,6 @@ def skill_manage(
Returns JSON string with results.
"""
# Approval gate: when on, stages the write for review (skills are too large
# to review inline, so they always stage regardless of origin); when off
# (default) passes straight through. The gate is bypassed when this call is
# itself replaying an already-approved staged write (_skill_apply_pending).
gate_result = _apply_skill_write_gate(
action, name, content=content, category=category,
file_path=file_path, file_content=file_content,
old_string=old_string, new_string=new_string,
replace_all=replace_all, absorbed_into=absorbed_into,
)
if gate_result is not None:
return gate_result
if action == "create":
if not content:
return tool_error("content is required for 'create'. Provide the full SKILL.md text (frontmatter + body).", success=False)
+44 -184
View File
@@ -301,7 +301,6 @@ class GitHubAuth:
result = subprocess.run(
["gh", "auth", "token"],
capture_output=True, text=True, timeout=5,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
@@ -551,8 +550,11 @@ class GitHubSource(SkillSource):
return [SkillMeta(**s) for s in cached]
url = f"https://api.github.com/repos/{repo}/contents/{path.rstrip('/')}"
resp = self._github_get(url)
if resp is None or resp.status_code != 200:
try:
resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15, follow_redirects=True)
if resp.status_code != 200:
return []
except httpx.HTTPError:
return []
entries = resp.json()
@@ -637,98 +639,15 @@ class GitHubSource(SkillSource):
def _check_rate_limit_response(self, resp: "httpx.Response") -> None:
"""Flag the instance as rate-limited when GitHub returns 403 + exhausted quota."""
if resp.status_code in (403, 429):
if resp.status_code == 403:
remaining = resp.headers.get("X-RateLimit-Remaining", "")
if remaining == "0" or resp.status_code == 429:
if remaining == "0":
self._rate_limited = True
logger.warning(
"GitHub API rate limit exhausted (unauthenticated: 60 req/hr). "
"Set GITHUB_TOKEN or install the gh CLI to raise the limit to 5,000/hr."
)
def _github_get(
self,
url: str,
*,
params: Optional[Dict] = None,
headers: Optional[Dict] = None,
timeout: float = 15.0,
max_retries: int = 3,
) -> Optional["httpx.Response"]:
"""GET against the GitHub API with retry/backoff on transient failures.
Returns the final ``httpx.Response`` (caller inspects status) or
``None`` when every attempt raised a transport error.
Retries on:
- 403/429 with ``X-RateLimit-Remaining: 0`` waits until the
reset time (capped) when the header is present, else exponential
backoff. This is the all-GitHub-tap-collapse case: a single
shared rate limit zeroes github + claude-marketplace + well-known
at once during the index build.
- 5xx and connection/timeout errors exponential backoff.
On terminal rate-limit exhaustion the instance is flagged via
``_check_rate_limit_response`` so the build can fail loud instead of
silently shipping an index with the GitHub sources dropped to zero.
"""
hdrs = headers if headers is not None else self.auth.get_headers()
backoff = 1.0
last_resp: Optional["httpx.Response"] = None
for attempt in range(max_retries):
try:
resp = httpx.get(
url, params=params, headers=hdrs,
timeout=timeout, follow_redirects=True,
)
except httpx.HTTPError as e:
logger.debug("GitHub GET %s failed (attempt %d/%d): %s",
url, attempt + 1, max_retries, e)
if attempt < max_retries - 1:
time.sleep(backoff)
backoff = min(backoff * 2, 30.0)
continue
return None
last_resp = resp
if resp.status_code == 200:
return resp
# Rate-limited: honor the reset header when present, else back off.
if resp.status_code in (403, 429):
remaining = resp.headers.get("X-RateLimit-Remaining", "")
is_rl = remaining == "0" or resp.status_code == 429
if is_rl and attempt < max_retries - 1:
wait = backoff
reset = resp.headers.get("X-RateLimit-Reset", "")
retry_after = resp.headers.get("Retry-After", "")
if retry_after.isdigit():
wait = min(float(retry_after), 60.0)
elif reset.isdigit():
delta = float(reset) - time.time()
if 0 < delta <= 60.0:
wait = delta
logger.debug(
"GitHub rate limited on %s, waiting %.1fs (attempt %d/%d)",
url, wait, attempt + 1, max_retries,
)
time.sleep(wait)
backoff = min(backoff * 2, 30.0)
continue
# Out of retries (or not a rate-limit 403) — flag and return.
self._check_rate_limit_response(resp)
return resp
# 5xx — retry; 4xx (other than rate limit) — return immediately.
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
time.sleep(backoff)
backoff = min(backoff * 2, 30.0)
continue
return resp
return last_resp
def _download_directory(self, repo: str, path: str) -> Dict[str, str]:
"""Recursively download all text files from a GitHub directory.
@@ -849,12 +768,17 @@ class GitHubSource(SkillSource):
def _fetch_file_content(self, repo: str, path: str) -> Optional[str]:
"""Fetch a single file's content from GitHub."""
url = f"https://api.github.com/repos/{repo}/contents/{path}"
resp = self._github_get(
url,
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
)
if resp is not None and resp.status_code == 200:
return resp.text
try:
resp = httpx.get(
url,
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
timeout=15, follow_redirects=True,
)
if resp.status_code == 200:
return resp.text
self._check_rate_limit_response(resp)
except httpx.HTTPError as e:
logger.debug("GitHub contents API fetch failed: %s", e)
return None
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
@@ -1946,12 +1870,6 @@ class ClawHubSource(SkillSource):
BASE_URL = "https://clawhub.ai/api/v1"
# Wall-clock budget for a full catalog walk. ClawHub has 50k+ skills and
# the walk is sequential (~250 requests, each under per-request
# timeout=30 so nothing errors), so an unbounded walk can block for
# minutes. Bound it so a slow/large catalog cannot hang the caller.
CATALOG_WALK_BUDGET_SECONDS = 12
def source_id(self) -> str:
return "clawhub"
@@ -2119,13 +2037,12 @@ class ClawHubSource(SkillSource):
if results:
return results
else:
# Empty query: route through the paginating catalog walker. When
# the full catalog is already disk-cached this returns it whole and
# the caller paginates client-side. On a cold cache, bound the walk
# to `limit` so a browse command renders its first page without
# walking the entire 50k+ catalog (max_items=0 → unbounded, used
# only by the offline index builder via search("", limit=0)).
catalog = self._load_catalog_index(max_items=limit if limit > 0 else 0)
# Empty query: route through the paginating catalog walker so the
# full ClawHub catalog (20k+ skills) lands in the index. The
# single-request listing path below caps at one page (200 items)
# regardless of `limit`, which silently truncates the public
# skills index. The catalog walker follows `nextCursor`.
catalog = self._load_catalog_index()
if catalog:
return self._dedupe_results(catalog)[:limit] if limit > 0 else self._dedupe_results(catalog)
@@ -2250,21 +2167,7 @@ class ClawHubSource(SkillSource):
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
return results
def _load_catalog_index(self, max_items: int = 0) -> List[SkillMeta]:
"""Walk the ClawHub catalog via cursor pagination.
``max_items`` bounds the walk: once at least that many distinct skills
have been gathered the walk stops early. This is what browse's
cold-start fallback wants it only renders one page, so walking the
entire 50k+ catalog just to slice off the first N is pure waste.
``max_items=0`` (the default, used by the offline index builder) means
walk to exhaustion.
Caching: only a *complete* catalog (cursor exhausted or page cap) is
written to the shared ``clawhub_catalog_v1`` cache. A walk truncated by
``max_items`` OR the wall-clock budget is partial, so caching it would
poison the full-catalog cache with an incomplete slice.
"""
def _load_catalog_index(self) -> List[SkillMeta]:
cache_key = "clawhub_catalog_v1"
cached = _read_index_cache(cache_key)
if cached is not None:
@@ -2279,22 +2182,8 @@ class ClawHubSource(SkillSource):
# terminates well before this on `nextCursor` going None — the cap is
# a safety rail against an infinite-cursor loop.
max_pages = 750
# Wall-clock budget is for interactive browse (max_items > 0) only.
# The offline index builder passes max_items=0 and must walk the full
# catalog — a 12s cap there ships ~3k skills and trips the deploy
# health floor (20k).
deadline = (
time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
if max_items > 0
else None
)
hit_deadline = False
hit_max_items = False
for _ in range(max_pages):
if deadline is not None and time.monotonic() > deadline:
hit_deadline = True
break
params: Dict[str, Any] = {"limit": 200}
if cursor:
params["cursor"] = cursor
@@ -2332,19 +2221,7 @@ class ClawHubSource(SkillSource):
if not isinstance(cursor, str) or not cursor:
break
# Browse's cold-start fallback only renders one page, so stop as
# soon as we have enough to satisfy the caller's bound. The index
# builder passes max_items=0 (unbounded) and walks to exhaustion.
if max_items > 0 and len(results) >= max_items:
hit_max_items = True
break
# Only cache a walk that reached a natural stop (cursor exhausted or
# page cap). A walk truncated by the wall-clock budget OR by max_items
# is partial, so writing it would poison the shared full-catalog cache
# with incomplete data.
if not hit_deadline and not hit_max_items:
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
return results
def _get_json(self, url: str, timeout: int = 20) -> Optional[Any]:
@@ -2496,19 +2373,10 @@ class ClaudeMarketplaceSource(SkillSource):
def __init__(self, auth: GitHubAuth):
self.auth = auth
# Persistent GitHubSource so rate-limit state survives across the
# marketplace-index fetch + per-skill inspect calls and can be
# surfaced to the index builder (see is_rate_limited).
self.github = GitHubSource(auth=auth)
def source_id(self) -> str:
return "claude-marketplace"
@property
def is_rate_limited(self) -> bool:
"""Whether the underlying GitHub API hit a rate limit during the crawl."""
return self.github.is_rate_limited
def trust_level_for(self, identifier: str) -> str:
parts = identifier.split("/", 2)
if len(parts) >= 2:
@@ -2547,13 +2415,15 @@ class ClaudeMarketplaceSource(SkillSource):
def fetch(self, identifier: str) -> Optional[SkillBundle]:
# Delegate to GitHub Contents API since marketplace skills live in GitHub repos
bundle = self.github.fetch(identifier)
gh = GitHubSource(auth=self.auth)
bundle = gh.fetch(identifier)
if bundle:
bundle.source = "claude-marketplace"
return bundle
def inspect(self, identifier: str) -> Optional[SkillMeta]:
meta = self.github.inspect(identifier)
gh = GitHubSource(auth=self.auth)
meta = gh.inspect(identifier)
if meta:
meta.source = "claude-marketplace"
meta.trust_level = self.trust_level_for(identifier)
@@ -2567,15 +2437,16 @@ class ClaudeMarketplaceSource(SkillSource):
return cached
url = f"https://api.github.com/repos/{repo}/contents/.claude-plugin/marketplace.json"
resp = self.github._github_get(
url,
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
)
if resp is None or resp.status_code != 200:
return []
try:
resp = httpx.get(
url,
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
timeout=15,
)
if resp.status_code != 200:
return []
data = json.loads(resp.text)
except json.JSONDecodeError:
except (httpx.HTTPError, json.JSONDecodeError):
return []
plugins = data.get("plugins", [])
@@ -3821,20 +3692,13 @@ def parallel_search_sources(
if not active:
return all_results, source_counts, timed_out_ids
# NOTE: a `with ThreadPoolExecutor(...) as pool` block calls
# ``shutdown(wait=True)`` on exit, which blocks until every submitted
# worker finishes — so a single slow source (e.g. ClawHub) keeps the
# caller blocked for minutes and renders ``overall_timeout`` a no-op.
# Manage the executor manually and shut it down with ``wait=False`` so
# the timeout is actually honoured.
pool = ThreadPoolExecutor(max_workers=min(len(active), 8))
futures = {}
for src in active:
lim = per_source_limits.get(src.source_id(), 50)
fut = pool.submit(_search_one_source, src, query, lim)
futures[fut] = src.source_id()
with ThreadPoolExecutor(max_workers=min(len(active), 8)) as pool:
futures = {}
for src in active:
lim = per_source_limits.get(src.source_id(), 50)
fut = pool.submit(_search_one_source, src, query, lim)
futures[fut] = src.source_id()
try:
try:
for fut in as_completed(futures, timeout=overall_timeout):
try:
@@ -3854,10 +3718,6 @@ def parallel_search_sources(
"Skills browse timed out waiting for: %s",
", ".join(timed_out_ids),
)
finally:
# wait=False so a slow source cannot block the caller's return;
# cancel_futures drops not-yet-started work.
pool.shutdown(wait=False, cancel_futures=True)
return all_results, source_counts, timed_out_ids
+1 -12
View File
@@ -1032,21 +1032,10 @@ def skill_view(
_record(None, categorized_path.with_suffix(".md"))
# Strategy 2: recursive by directory name (catches nested skills
# like "foundations/runtime/explore-codebase" called by bare name),
# plus frontmatter `name:` lookup. `skills_list()` exposes the
# frontmatter name, so `skill_view(name)` must accept it too even
# when the on-disk directory is a shorter category/alias.
# like "foundations/runtime/explore-codebase" called by bare name).
for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"):
if found_skill_md.parent.name == name:
_record(found_skill_md.parent, found_skill_md)
continue
try:
fm_content = found_skill_md.read_text(encoding="utf-8")
fm, _ = _parse_frontmatter(fm_content)
except Exception:
fm = {}
if fm.get("name") == name:
_record(found_skill_md.parent, found_skill_md)
# Strategy 3: legacy flat <name>.md files anywhere under the dir.
for found_md in search_dir.rglob(f"{name}.md"):
+13 -45
View File
@@ -777,8 +777,7 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
the password in the command string themselves; see their execute()
methods for how they handle the non-None sudo_stdin case.
If SUDO_PASSWORD is not set and an interactive UI is available
(HERMES_INTERACTIVE=1 or a registered sudo password callback):
If SUDO_PASSWORD is not set and in interactive mode (HERMES_INTERACTIVE=1):
Prompts user for password with 45s timeout, caches for session.
If SUDO_PASSWORD is not set and NOT interactive:
@@ -806,11 +805,7 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
if not has_configured_password and not sudo_password and _sudo_nopasswd_works():
return command, None
has_sudo_prompt_callback = _get_sudo_password_callback() is not None
should_prompt_for_sudo = (
env_var_enabled("HERMES_INTERACTIVE") or has_sudo_prompt_callback
)
if not has_configured_password and not sudo_password and should_prompt_for_sudo:
if not has_configured_password and not sudo_password and env_var_enabled("HERMES_INTERACTIVE"):
sudo_password = _prompt_for_sudo_password(timeout_seconds=45)
if sudo_password:
_set_cached_sudo_password(sudo_password)
@@ -834,7 +829,7 @@ import sys
# Tool description for LLM
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem, current working directory, and exported environment variables persist between calls.
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem usually persists between calls.
Do NOT use cat/head/tail to read files use read_file instead.
Do NOT use grep/rg/find to search use search_files instead.
@@ -842,7 +837,6 @@ Do NOT use ls to list directories — use search_files(target='files') instead.
Do NOT use sed/awk to edit files use patch instead.
Do NOT use echo/cat heredoc to create files use write_file instead.
Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.
Because exported environment state persists, activate a virtualenv or export setup variables once per session; do not re-source the same environment before every command unless a command proves the shell state was reset.
Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts you'll still get the result in seconds if it's fast. Prefer foreground for short commands.
Background: Set background=true to get a session_id. Almost always pair with notify_on_complete=true bg without notify runs SILENTLY and you have no way to learn it finished short of calling process(action='poll') yourself. Two legitimate uses:
@@ -1036,7 +1030,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str:
# Configuration from environment variables
def _parse_env_var(name: str, default: str, converter: Any = int, type_label: str = "integer"):
def _parse_env_var(name: str, default: str, converter=int, type_label: str = "integer"):
"""Parse an environment variable with *converter*, raising a clear error on bad values.
Without this wrapper, a single malformed env var (e.g. TERMINAL_TIMEOUT=5m)
@@ -1073,32 +1067,6 @@ def _get_env_config() -> Dict[str, Any]:
env_type = os.getenv("TERMINAL_ENV", "local")
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"}
container_backend = env_type in {"docker", "singularity", "modal", "daytona"}
docker_backend = env_type == "docker"
# Docker/container-only env vars may be bridged from config.yaml even when
# the active backend is local/ssh. Do not parse their JSON/numeric payloads
# until a backend that can consume them is selected; a stale or invalid
# Docker value should not make local terminal/execute_code unusable.
if container_backend:
container_cpu = _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number")
container_memory = _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120")
container_disk = _parse_env_var("TERMINAL_CONTAINER_DISK", "51200")
else:
container_cpu = 1.0
container_memory = 5120
container_disk = 51200
if docker_backend:
docker_forward_env = _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON")
docker_volumes = _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON")
docker_env = _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON")
docker_extra_args = _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON")
else:
docker_forward_env = []
docker_volumes = []
docker_env = {}
docker_extra_args = []
# Default cwd: local uses the host's current directory, ssh uses the
# remote home, and everything else starts in the backend's default
@@ -1142,7 +1110,7 @@ def _get_env_config() -> Dict[str, Any]:
"env_type": env_type,
"modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
"docker_forward_env": docker_forward_env,
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
@@ -1166,14 +1134,14 @@ def _get_env_config() -> Dict[str, Any]:
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"},
# Container resource config (applies to docker, singularity, modal,
# daytona -- ignored for local/ssh)
"container_cpu": container_cpu,
"container_memory": container_memory, # MB (default 5GB)
"container_disk": container_disk, # MB (default 50GB)
"container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
"container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
"container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"},
"docker_volumes": docker_volumes,
"docker_env": docker_env,
"docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"),
"docker_env": _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON"),
"docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"},
"docker_extra_args": docker_extra_args,
"docker_extra_args": _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON"),
# Cross-process container reuse (issue #20561). The docs claim
# "ONE long-lived container shared across sessions" — this toggle
# makes that real by probing for a labeled container at startup and
@@ -2468,13 +2436,13 @@ def check_terminal_requirements() -> bool:
if not docker:
logger.error("Docker executable not found in PATH or common install locations")
return False
result = subprocess.run([docker, "version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
return result.returncode == 0
elif env_type == "singularity":
executable = shutil.which("apptainer") or shutil.which("singularity")
if executable:
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5)
return result.returncode == 0
return False
-2
View File
@@ -288,7 +288,6 @@ def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool |
capture_output=True,
text=True,
timeout=15,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
logger.info("cosign provenance verification passed")
@@ -735,7 +734,6 @@ def check_command_security(command: str) -> dict:
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except OSError as exc:
# Covers FileNotFoundError, PermissionError, exec format error.
+4 -5
View File
@@ -490,7 +490,6 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
stdin=subprocess.DEVNULL,
)
except Exception:
proc.kill()
@@ -556,7 +555,7 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
proc = subprocess.Popen(command, **popen_kwargs)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
@@ -1187,7 +1186,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str],
command = [ffmpeg, "-y", "-i", file_path, converted_path]
try:
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300)
return converted_path, None
except subprocess.TimeoutExpired:
logger.error("ffmpeg conversion timed out for %s", file_path)
@@ -1233,9 +1232,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
# User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
if use_shell:
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300)
else:
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300)
txt_files = sorted(Path(output_dir).glob("*.txt"))
+25 -205
View File
@@ -190,8 +190,6 @@ DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
DEFAULT_GEMINI_TTS_VOICE = "Kore"
DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
DEFAULT_GEMINI_AUDIO_TAGS = False
GEMINI_AUDIO_TAG_REWRITE_TASK = "tts_audio_tags"
# PCM output specs for Gemini TTS (fixed by the API)
GEMINI_TTS_SAMPLE_RATE = 24000
GEMINI_TTS_CHANNELS = 1
@@ -206,8 +204,8 @@ DEFAULT_OUTPUT_DIR = _get_default_output_dir()
# ---------------------------------------------------------------------------
# Per-provider input-character limits (from official provider docs).
# A single global cap was wrong: OpenAI is 4096, xAI is 15k, MiniMax is 10k,
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini has a 32k-token
# context window. Users can override any of these via
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini caps at ~8k
# input tokens. Users can override any of these via
# ``tts.<provider>.max_text_length`` in config.yaml.
# ---------------------------------------------------------------------------
PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
@@ -216,7 +214,7 @@ PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
"xai": 15000, # https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
"minimax": 10000, # https://platform.minimax.io/docs/api-reference/speech-t2a-http (sync)
"mistral": 4000, # conservative; no published per-request cap
"gemini": 32000, # Gemini TTS has a 32k-token context window; char cap is conservative
"gemini": 5000, # Gemini TTS caps at ~8k input tokens / ~655s audio
"elevenlabs": 10000, # fallback when model-aware lookup can't resolve (multilingual_v2)
"neutts": 2000, # local model, quality falls off on long text
"kittentts": 2000, # local 25MB model
@@ -235,23 +233,6 @@ ELEVENLABS_MODEL_MAX_TEXT_LENGTH: Dict[str, int] = {
"eleven_flash_v2_5": 40000,
}
def _config_bool(value: Any, default: bool = False) -> bool:
"""Coerce common YAML/env bool spellings without treating random strings as true."""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on", "enabled"}:
return True
if normalized in {"0", "false", "no", "off", "disabled"}:
return False
return default
# Final fallback when provider isn't recognised at all.
FALLBACK_MAX_TEXT_LENGTH = 4000
@@ -712,7 +693,6 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
stdin=subprocess.DEVNULL,
)
except Exception:
proc.kill()
@@ -765,7 +745,7 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
proc = subprocess.Popen(command, **popen_kwargs)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
@@ -902,7 +882,6 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]:
["ffmpeg", "-i", mp3_path, "-acodec", "libopus",
"-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.warning("ffmpeg conversion failed with return code %d: %s",
@@ -1088,7 +1067,20 @@ _XAI_FIRST_SENTENCE_RE = re.compile(r"^(.{12,120}?[.!?…])\s+(?=\S)", flags=re.
def _xai_bool_config(value: Any, default: bool = False) -> bool:
return _config_bool(value, default=default)
"""Coerce common YAML/env bool spellings without treating random strings as true."""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on", "enabled"}:
return True
if normalized in {"0", "false", "no", "off", "disabled"}:
return False
return default
def _apply_xai_auto_speech_tags(text: str) -> str:
@@ -1400,160 +1392,6 @@ def _wrap_pcm_as_wav(
return riff_header + fmt_chunk + data_chunk_header + pcm_bytes
def _resolve_gemini_persona_prompt_path(gemini_config: Dict[str, Any]) -> Optional[Path]:
"""Return the configured persona prompt file path, if any."""
raw = gemini_config.get("persona_prompt_file")
if not isinstance(raw, str) or not raw.strip():
return None
expanded = os.path.expandvars(raw.strip())
path = Path(expanded).expanduser()
if not path.is_absolute():
try:
from hermes_constants import get_hermes_home
path = get_hermes_home() / path
except Exception:
path = Path.cwd() / path
return path
def _read_gemini_persona_prompt(gemini_config: Dict[str, Any]) -> str:
"""Read the Gemini persona prompt file, failing soft on config mistakes."""
path = _resolve_gemini_persona_prompt_path(gemini_config)
if path is None:
return ""
try:
return path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError) as exc:
logger.warning(
"Gemini TTS persona prompt file unavailable at %s: %s",
path,
exc,
)
return ""
def _gemini_model_supports_audio_tags(model: str) -> bool:
"""Return True for Gemini TTS models known to support expressive audio tags."""
normalized = (model or "").strip().lower().rsplit("/", 1)[-1]
return "gemini-3.1" in normalized and "tts" in normalized
def _gemini_audio_tags_enabled(gemini_config: Dict[str, Any], model: str) -> bool:
raw = gemini_config.get("audio_tags")
if isinstance(raw, dict):
raw = raw.get("enabled")
enabled = _config_bool(raw, default=DEFAULT_GEMINI_AUDIO_TAGS)
if not enabled:
return False
if not _gemini_model_supports_audio_tags(model):
logger.warning(
"Gemini TTS audio_tags enabled, but model %s is not known to support "
"Gemini audio tags; skipping hidden tag rewrite",
model,
)
return False
return True
def _clean_gemini_audio_tag_rewrite(content: str) -> str:
clean = (content or "").strip()
fence = re.fullmatch(r"```(?:[A-Za-z0-9_-]+)?\s*(.*?)\s*```", clean, flags=re.DOTALL)
if fence:
clean = fence.group(1).strip()
return clean
def _extract_auxiliary_message_content(response: Any) -> str:
try:
choice = response.choices[0]
message = getattr(choice, "message", None)
if isinstance(message, dict):
return str(message.get("content") or "")
return str(getattr(message, "content", "") or "")
except Exception:
return ""
def _rewrite_gemini_tts_audio_tags(text: str, persona_prompt: str = "") -> str:
"""Use the configured auxiliary model to insert Gemini audio tags."""
transcript = text.strip()
if not transcript:
return text
system_prompt = (
"You rewrite transcripts for Gemini 3.1 Flash TTS by inserting expressive "
"audio tags.\n\n"
"Audio tags are inline square-bracket modifiers such as [whispers], "
"[excitedly], [very slow], [sarcastically], [laughs], [sighs], or [gasp]. "
"There is no fixed allowlist. Use creative freeform tags generously but "
"naturally to control tone, pace, emotional vibe, emphasis, section-level "
"delivery, and non-verbal sounds. Use English audio tags even when the "
"spoken transcript is not English.\n\n"
"Rules:\n"
"- Preserve the spoken words, order, and meaning.\n"
"- Do not add new spoken sentences or remove existing spoken words.\n"
"- Use square brackets for every audio tag.\n"
"- Do not use SSML or XML tags.\n"
"- Do not explain or comment.\n"
"- Return only the tagged TTS script."
)
context = persona_prompt.strip() or "(none)"
user_prompt = (
"PERSONA AND DIRECTOR CONTEXT:\n"
f"{context}\n\n"
"TRANSCRIPT TO TAG:\n"
f"{transcript}"
)
try:
from agent.auxiliary_client import call_llm
response = call_llm(
task=GEMINI_AUDIO_TAG_REWRITE_TASK,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
)
tagged = _clean_gemini_audio_tag_rewrite(_extract_auxiliary_message_content(response))
return tagged or text
except Exception as exc:
logger.warning("Gemini TTS audio tag rewrite failed; using untagged text: %s", exc)
return text
def _compose_gemini_tts_prompt(
text: str,
gemini_config: Dict[str, Any],
persona_prompt: Optional[str] = None,
) -> str:
"""Build the Gemini prompt from persona direction plus the live transcript."""
transcript = text.strip()
if persona_prompt is None:
persona_prompt = _read_gemini_persona_prompt(gemini_config)
if not persona_prompt:
return transcript
preamble = (
"Synthesize speech from the TRANSCRIPT only. Treat AUDIO PROFILE, "
"SCENE, DIRECTOR'S NOTES, and SAMPLE CONTEXT as performance direction; "
"do not speak those sections aloud."
)
placeholder_patterns = (
re.compile(r"\{\{\s*transcript\s*\}\}", flags=re.IGNORECASE),
re.compile(r"\{\s*transcript\s*\}", flags=re.IGNORECASE),
)
prompt = persona_prompt
for pattern in placeholder_patterns:
if pattern.search(prompt):
prompt = pattern.sub(transcript, prompt)
return f"{preamble}\n\n{prompt}".strip()
return f"{preamble}\n\n{persona_prompt}\n\n#### TRANSCRIPT\n{transcript}".strip()
def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
"""Generate audio using Google Gemini TTS.
@@ -1579,8 +1417,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
"GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey"
)
raw_gemini_config = tts_config.get("gemini", {})
gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {}
gemini_config = tts_config.get("gemini", {})
model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL
voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE
base_url = str(
@@ -1588,25 +1425,9 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
or get_env_value("GEMINI_BASE_URL")
or DEFAULT_GEMINI_TTS_BASE_URL
).strip().rstrip("/")
persona_prompt = _read_gemini_persona_prompt(gemini_config)
tts_script = text
if _gemini_audio_tags_enabled(gemini_config, model):
tts_script = _rewrite_gemini_tts_audio_tags(text, persona_prompt=persona_prompt)
prompt_text = _compose_gemini_tts_prompt(
tts_script,
gemini_config,
persona_prompt=persona_prompt,
)
max_len = _resolve_max_text_length("gemini", tts_config)
if len(prompt_text) > max_len:
logger.warning(
"Gemini TTS composed prompt too long (%d chars), truncating to %d",
len(prompt_text), max_len,
)
prompt_text = prompt_text[:max_len]
payload: Dict[str, Any] = {
"contents": [{"parts": [{"text": prompt_text}]}],
"contents": [{"parts": [{"text": text}]}],
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {
@@ -1683,7 +1504,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
]
else:
cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, timeout=30)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
@@ -1766,7 +1587,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
"--device", device,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
stderr = result.stderr.strip()
# Filter out the "OK:" line from stderr
@@ -1778,7 +1599,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
subprocess.run(conv_cmd, check=True, timeout=30)
os.remove(wav_path)
else:
# No ffmpeg — just rename the WAV to the expected path
@@ -1849,7 +1670,6 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
[_sys.executable, "-m", "piper.download_voices", voice,
"--download-dir", str(download_dir)],
capture_output=True, text=True, timeout=300,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
@@ -1937,7 +1757,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
subprocess.run(conv_cmd, check=True, timeout=30)
try:
os.remove(wav_path)
except OSError:
@@ -2003,7 +1823,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
subprocess.run(conv_cmd, check=True, timeout=30)
os.remove(wav_path)
else:
# No ffmpeg — rename the WAV to the expected path
+3 -4
View File
@@ -75,7 +75,6 @@ def _termux_api_app_installed() -> bool:
text=True,
timeout=5,
check=False,
stdin=subprocess.DEVNULL,
)
return "package:com.termux.api" in (result.stdout or "")
except Exception:
@@ -389,7 +388,7 @@ class TermuxAudioRecorder:
"-c", str(CHANNELS),
]
try:
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True, stdin=subprocess.DEVNULL)
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True)
except subprocess.CalledProcessError as e:
details = (e.stderr or e.stdout or str(e)).strip()
raise RuntimeError(f"Termux microphone start failed: {details}") from e
@@ -406,7 +405,7 @@ class TermuxAudioRecorder:
mic_cmd = _termux_microphone_command()
if not mic_cmd:
return
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False, stdin=subprocess.DEVNULL)
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False)
def stop(self) -> Optional[str]:
with self._lock:
@@ -1096,7 +1095,7 @@ def play_audio_file(file_path: str) -> bool:
exe = shutil.which(cmd[0])
if exe:
try:
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with _playback_lock:
_active_playback = proc
proc.wait(timeout=300)
+26 -221
View File
@@ -141,71 +141,36 @@ def _load_web_config() -> dict:
except (ImportError, Exception):
return {}
# Recognized web backend names (config values accepted in ``web.backend`` /
# ``web.search_backend`` / ``web.extract_backend``). Kept as a single source of
# truth for config validation across the selection helpers.
_KNOWN_WEB_BACKENDS = frozenset(
{"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}
)
# Backends that only service web_search (their provider's ``supports_extract()``
# is False). They are skipped during *extract* auto-detect so a search-only
# credential (e.g. SEARXNG_URL) does not shadow the keyless Parallel free-MCP
# fallback, which would otherwise leave web_extract broken on a no-key install.
_SEARCH_ONLY_BACKENDS = frozenset({"searxng", "brave-free", "ddgs", "xai"})
def _get_backend(capability: str = "search") -> str:
def _get_backend() -> str:
"""Determine which web backend to use (shared fallback).
Reads ``web.backend`` from config.yaml (set by ``hermes tools``).
Falls back to whichever API key is present for users who configured
keys manually without running setup.
``capability`` ("search" | "extract") only affects auto-detect: for
``extract`` we skip search-only backends (``_SEARCH_ONLY_BACKENDS``) so a
search-only credential never shadows the keyless Parallel free-MCP extract
fallback. An explicit ``web.backend`` value is honored as-is (explicit wins,
surfacing that backend's own search-only error rather than rerouting).
"""
configured = (_load_web_config().get("backend") or "").lower().strip()
if configured in _KNOWN_WEB_BACKENDS:
if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}:
return configured
# Fallback for manual / legacy config — pick the highest-priority
# available backend. Explicit user credentials (TAVILY_API_KEY etc.)
# beat the managed-tool-gateway probe so a deliberate setup is not
# pre-empted by a Nous OAuth token whose subscription tier may not
# actually grant web-search access (the gateway then fails at runtime
# with "no subscription" and the tool returns an error to the agent
# without falling back). Free-tier backends (searxng / brave-free /
# keyless parallel / ddgs) trail the keyed ones.
# available backend. Firecrawl also counts as available when the managed
# tool gateway is configured for Nous subscribers.
# Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so
# existing paid setups are unaffected.
backend_candidates = (
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
("parallel", _has_env("PARALLEL_API_KEY")),
("tavily", _has_env("TAVILY_API_KEY")),
("exa", _has_env("EXA_API_KEY")),
("parallel", _has_env("PARALLEL_API_KEY")),
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL")),
("firecrawl", _is_tool_gateway_ready()),
("searxng", _has_env("SEARXNG_URL")),
("brave-free", _has_env("BRAVE_SEARCH_API_KEY")),
# Keyless Parallel free MCP — always available, the intended no-key
# default for both search and extract. Ahead of ddgs (search-only, so it
# can't service web_extract); ddgs stays reachable via web.backend=ddgs.
("parallel", True),
("ddgs", _ddgs_package_importable()),
)
for backend, available in backend_candidates:
if not available:
continue
# For extract, skip search-only backends so the keyless Parallel
# free-MCP fallback (which can fetch URLs) is reached instead.
if capability == "extract" and backend in _SEARCH_ONLY_BACKENDS:
continue
return backend
if available:
return backend
# Defensive terminal (the keyless ``parallel`` candidate above is always
# available, so this is effectively unreachable).
return "parallel"
return "firecrawl" # default (backward compat)
def _get_search_backend() -> str:
@@ -236,19 +201,14 @@ def _get_extract_backend() -> str:
def _get_capability_backend(capability: str) -> str:
"""Shared helper for per-capability backend selection.
Reads ``web.{capability}_backend`` from config. Any explicit value is
honored **regardless of availability** including unrecognized typos like
``parrallel`` so the dispatcher surfaces that backend's own setup/config
error rather than silently rerouting to the keyless Parallel default (which
would send user queries to a different provider and hide the
misconfiguration). This matches ``web_search_registry``'s "explicit config
wins" rule. Only an *unset* value falls through to ``_get_backend()``.
Reads ``web.{capability}_backend`` from config; if set and available,
uses it. Otherwise falls through to the shared ``_get_backend()``.
"""
cfg = _load_web_config()
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
if specific:
if specific and _is_backend_available(specific):
return specific
return _get_backend(capability)
return _get_backend()
def _is_backend_available(backend: str) -> bool:
@@ -256,8 +216,6 @@ def _is_backend_available(backend: str) -> bool:
if backend == "exa":
return _has_env("EXA_API_KEY")
if backend == "parallel":
# Credential probe: True only with a real key. The keyless free-MCP
# fallback is handled by _get_backend()'s terminal default, not here.
return _has_env("PARALLEL_API_KEY")
if backend == "firecrawl":
return check_firecrawl_api_key()
@@ -810,17 +768,6 @@ def _ensure_web_plugins_loaded() -> None:
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
the underlying discovery call is idempotent and cheap on subsequent
invocations.
Triggering discovery is necessary but not *sufficient*: the sweep can
finish without registering the bundled web providers (its exception
swallowed below as a warning, a packaged layout where discovery ran before
the bundled tree was importable, or a stale empty-discovery cache). When
that happens the registry is empty and *both* web_search and web_extract
dead-end on "No web {search,extract} provider configured" even though the
keyless Parallel default is supposed to work with zero setup. So after
discovery we verify the keyless default landed and, if not, register the
bundled providers directly (see
:func:`_register_bundled_web_providers_directly`).
"""
try:
from hermes_cli.plugins import _ensure_plugins_discovered
@@ -833,87 +780,6 @@ def _ensure_web_plugins_loaded() -> None:
# clue in normal logs about the real cause.
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
# Belt-and-suspenders: guarantee the keyless Parallel default (the
# documented zero-setup backend for both web_search and web_extract) is
# actually registered. The lookup is a cheap dict hit on the healthy path
# (discovery already registered it → no-op); only an empty registry pays
# for the direct-registration sweep.
try:
from agent.web_search_registry import get_provider
if get_provider("parallel") is None:
_register_bundled_web_providers_directly()
except Exception as exc: # noqa: BLE001
logger.debug("Bundled web provider fallback check failed: %s", exc)
def _register_bundled_web_providers_directly() -> None:
"""Register the repo's bundled web providers without the plugin manager.
The normal path is the general plugin sweep
(:func:`hermes_cli.plugins._ensure_plugins_discovered`), which auto-loads
every ``plugins/web/<name>`` backend (they are ``kind: backend``). This
fallback exists for the runtimes where that sweep does not leave the web
registry populated so the keyless Parallel default (and any bundled
backend the user explicitly configured) keeps working instead of
surfacing a misleading "No web provider configured" error.
Imports each bundled ``plugins/web/<name>`` package and calls its
``register()`` directly against :mod:`agent.web_search_registry`. Idempotent
(re-register overwrites) and honors an explicit ``plugins.disabled`` entry
so a backend the user turned off stays off.
"""
try:
from hermes_cli.plugins import (
_get_disabled_plugins,
get_bundled_plugins_dir,
)
except Exception as exc: # noqa: BLE001
logger.debug("Bundled web provider fallback unavailable: %s", exc)
return
web_dir = get_bundled_plugins_dir() / "web"
if not web_dir.is_dir():
return
disabled = _get_disabled_plugins()
from agent.web_search_provider import WebSearchProvider
from agent.web_search_registry import register_provider
class _DirectRegistrationCtx:
"""Minimal plugin ctx exposing only web-provider registration."""
def register_web_search_provider(self, provider) -> None:
if isinstance(provider, WebSearchProvider):
register_provider(provider)
ctx = _DirectRegistrationCtx()
import importlib
for child in sorted(web_dir.iterdir()):
if not child.is_dir():
continue
if not (child / "plugin.yaml").exists() and not (child / "plugin.yml").exists():
continue
# Respect an explicit disable — match discover_and_load's key/name
# check (key ``web/<dir>``; manifest name ``web-<dir-with-dashes>``).
if (
f"web/{child.name}" in disabled
or f"web-{child.name.replace('_', '-')}" in disabled
):
continue
try:
module = importlib.import_module(f"plugins.web.{child.name}")
register_fn = getattr(module, "register", None)
if callable(register_fn):
register_fn(ctx)
except Exception as exc: # noqa: BLE001
logger.debug(
"Direct registration of bundled web provider '%s' failed: %s",
child.name, exc,
)
def web_search_tool(query: str, limit: int = 5) -> str:
"""
@@ -1103,19 +969,11 @@ async def web_extract_tool(
else:
safe_urls.append(url)
# Tracks the free-tier Parallel extract path (no key → web_fetch via the
# hosted Search MCP) so we can credit Parallel in the output/UI. Bound
# here so empty/all-blocked inputs (which skip dispatch) stay defined.
_free_parallel_extract = False
# Dispatch only safe URLs to the configured backend
if not safe_urls:
results = []
else:
backend = _get_extract_backend()
_free_parallel_extract = (
backend == "parallel" and not _has_env("PARALLEL_API_KEY")
)
# All seven providers (brave-free, ddgs, searxng, exa, parallel,
# tavily, firecrawl) now live as plugins. The dispatcher is a
@@ -1289,14 +1147,6 @@ async def web_extract_tool(
for r in response.get("results", [])
]
trimmed_response = {"results": trimmed_results}
if _free_parallel_extract:
# Credit Parallel's free Search MCP (drives the "[Parallel]" UI tag
# + lets the model cite the source). Free tier only.
trimmed_response["provider"] = "parallel"
trimmed_response["attribution"] = (
"Extraction powered by the free Parallel Web Search MCP "
"(https://parallel.ai)."
)
if trimmed_response.get("results") == []:
result_json = tool_error("Content was inaccessible or not found")
@@ -1328,61 +1178,16 @@ async def web_extract_tool(
return tool_error(error_msg)
def web_tools_registered() -> bool:
"""Whether the web tools should be registered. Always True.
Registration is decoupled from credential readiness: with no credentials,
search/extract fall back to Parallel's free hosted Search MCP, and an
explicitly configured-but-unavailable backend must stay registered so
dispatch surfaces that backend's own setup error rather than the tool
silently vanishing. For "is web actually configured?" use
:func:`check_web_api_key`.
"""
return True
def _parallel_provider_registered() -> bool:
"""True when the bundled ``web-parallel`` provider is registered/enabled.
Plugin discovery skips disabled plugins, so a disabled (``plugins.disabled``)
or otherwise-unregistered parallel provider yields ``None`` here.
"""
_ensure_web_plugins_loaded()
try:
from agent.web_search_registry import get_provider
return get_provider("parallel") is not None
except Exception: # noqa: BLE001
return False
def _backend_usable(backend: str) -> bool:
"""True when *backend* can service calls. Keyless Parallel counts (free MCP).
Unknown/typo'd backend names are not usable (so an explicit typo is reported
as a config problem rather than masked by the keyless fallback).
"""
if backend == "parallel" and not _has_env("PARALLEL_API_KEY"):
# Keyless Parallel is only genuinely usable when its provider is actually
# registered/enabled. If web-parallel is disabled or discovery failed,
# report unusable so setup is not skipped and the user is not left with
# web tools that fail at runtime ("No web search provider configured").
return _parallel_provider_registered()
return _is_backend_available(backend)
# Convenience function to check Firecrawl credentials
def check_web_api_key() -> bool:
"""Usability probe: True when the selected web backends can service calls.
Probes the backends that :func:`_get_search_backend` /
:func:`_get_extract_backend` actually select (not just shared
``web.backend``), so an explicit per-capability backend with missing
credentials or a typo'd name — reports unusable instead of being masked by
the keyless Parallel fallback. Keyless Parallel itself genuinely services
calls, so a zero-setup install reports usable. Distinct from
:func:`web_tools_registered` (always True whether the tool is offered).
"""
return _backend_usable(_get_search_backend()) and _backend_usable(_get_extract_backend())
"""Check whether the configured web backend is available."""
configured = _load_web_config().get("backend", "").lower().strip()
if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai"}:
return _is_backend_available(configured)
return any(
_is_backend_available(backend)
for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai")
)
def check_auxiliary_model() -> bool:
@@ -1550,7 +1355,7 @@ registry.register(
toolset="web",
schema=WEB_SEARCH_SCHEMA,
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=args.get("limit", 5)),
check_fn=web_tools_registered,
check_fn=check_web_api_key,
requires_env=_web_requires_env(),
emoji="🔍",
max_result_size_chars=100_000,
@@ -1561,7 +1366,7 @@ registry.register(
schema=WEB_EXTRACT_SCHEMA,
handler=lambda args, **kw: web_extract_tool(
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
check_fn=web_tools_registered,
check_fn=check_web_api_key,
requires_env=_web_requires_env(),
is_async=True,
emoji="📄",
-493
View File
@@ -1,493 +0,0 @@
#!/usr/bin/env python3
"""Write-approval gate + pending store for memory and skill writes.
Background
----------
The agent writes to two persistent stores that survive across sessions:
* **memory** MEMORY.md / USER.md, small (~200 char) declarative entries
* **skills** SKILL.md + supporting files, potentially huge (10-100 KB)
Both stores are written from two origins:
* **foreground** a normal agent turn (user is present / chatting)
* **background_review** the self-improvement review fork that runs after a
turn and autonomously decides what to save (the source of the
"wrong assumptions" users complained about)
This module lets the user gate those writes per-subsystem with a boolean
``write_approval``:
* ``false`` (default) write freely (the pre-gate behaviour)
* ``true`` require approval: do not commit the write; either
prompt inline (memory, interactive CLI only) or **stage** it to a pending
store and surface it for the user to approve or reject out-of-band
The size asymmetry between memory and skills is real and unavoidable: a memory
entry can be reviewed inline in a chat bubble; a 100 KB SKILL.md cannot. So
the gate stages BOTH to disk, but review affordances differ by subsystem
(see ``hermes_cli`` slash handlers): memory shows full content, skills show
metadata + a one-line gist + a ``diff`` escape hatch (CLI/dashboard/file).
Staging is mandatory for background-origin writes (a daemon thread cannot
block on an interactive prompt) and for gateway sessions (no inline prompt
channel review happens via ``/memory pending``). Foreground CLI memory
writes prompt inline via the dangerous-command approval callback; skill
writes always stage (too big to eyeball mid-loop).
Pending records live under ``<HERMES_HOME>/pending/{memory,skills}/<id>.json``
so they survive process restarts and can be reviewed from CLI, gateway, or the
web dashboard.
"""
from __future__ import annotations
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Subsystem identifiers
MEMORY = "memory"
SKILLS = "skills"
_SUBSYSTEMS = (MEMORY, SKILLS)
# Config key (per subsystem). A single boolean: the approval gate is OFF by
# default (writes flow freely, the pre-gate behaviour), and ON means stage /
# prompt every write for the user's approval. There is intentionally no third
# "block all writes" state — to disable a subsystem entirely use its own
# enable flag (e.g. ``memory.memory_enabled: false``).
CONFIG_KEY = "write_approval"
# ---------------------------------------------------------------------------
# Config resolution
# ---------------------------------------------------------------------------
def write_approval_enabled(subsystem: str) -> bool:
"""Return whether the approval gate is enabled for ``subsystem``.
Reads ``<subsystem>.write_approval`` from config.yaml. Defaults to
``False`` (gate off writes flow freely) for any unset / invalid value so
existing installs keep their current behaviour until the user opts in.
"""
if subsystem not in _SUBSYSTEMS:
return False
try:
from hermes_cli.config import load_config, cfg_get
cfg = load_config()
raw = cfg_get(cfg, subsystem, CONFIG_KEY, default=False)
except Exception:
return False
return _normalize_enabled(raw)
def _normalize_enabled(value: Any) -> bool:
"""Coerce a config value to a bool. Default (unknown) is False (gate off).
Accepts real bools and the usual truthy/falsey strings. YAML 1.1 parses
bare ``on``/``off``/``yes``/``no`` as bools already, so the string branch
is mostly for hand-edited configs.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"on", "true", "yes", "1", "approve", "enabled"}
return False
# ---------------------------------------------------------------------------
# Pending store (file-backed)
# ---------------------------------------------------------------------------
def _pending_dir(subsystem: str) -> Path:
return get_hermes_home() / "pending" / subsystem
def stage_write(subsystem: str, payload: Dict[str, Any],
*, summary: str, origin: str) -> Dict[str, Any]:
"""Persist a pending write and return a short record describing it.
Args:
subsystem: ``memory`` or ``skills``.
payload: the exact kwargs needed to replay the write when approved
(e.g. ``{"action": "add", "target": "user", "content": "..."}``
for memory, or the full ``skill_manage`` kwargs for skills).
summary: a one-line human-readable description shown in pending lists.
For skills this is the LLM/heuristic gist; for memory it can be the
entry text itself.
origin: ``foreground`` or ``background_review`` recorded for audit.
Returns a dict with ``id`` and metadata. Best-effort: on disk failure it
logs and still returns a record (the write is simply lost, which is the
safe failure for an approval gate nothing is silently committed).
"""
pid = uuid.uuid4().hex[:8]
record = {
"id": pid,
"subsystem": subsystem,
"action": payload.get("action", ""),
"summary": (summary or "").strip(),
"origin": origin or "foreground",
"created_at": time.time(),
"payload": payload,
}
try:
d = _pending_dir(subsystem)
d.mkdir(parents=True, exist_ok=True)
path = d / f"{pid}.json"
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
except Exception as e: # pragma: no cover - disk failure path
logger.error("Failed to stage pending %s write: %s", subsystem, e, exc_info=True)
return record
def list_pending(subsystem: str) -> List[Dict[str, Any]]:
"""Return all pending records for ``subsystem``, oldest first."""
d = _pending_dir(subsystem)
if not d.exists():
return []
records: List[Dict[str, Any]] = []
for p in d.glob("*.json"):
try:
records.append(json.loads(p.read_text(encoding="utf-8")))
except Exception:
logger.warning("Skipping unreadable pending record: %s", p)
records.sort(key=lambda r: r.get("created_at", 0))
return records
def get_pending(subsystem: str, pending_id: str) -> Optional[Dict[str, Any]]:
"""Return a single pending record by id, or None."""
path = _pending_dir(subsystem) / f"{pending_id}.json"
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def discard_pending(subsystem: str, pending_id: str) -> bool:
"""Delete a pending record. Returns True if it existed."""
path = _pending_dir(subsystem) / f"{pending_id}.json"
try:
if path.exists():
path.unlink()
return True
except Exception as e: # pragma: no cover
logger.error("Failed to discard pending %s/%s: %s", subsystem, pending_id, e)
return False
def pending_count(subsystem: str) -> int:
"""Cheap count of pending records (for notification badges)."""
d = _pending_dir(subsystem)
if not d.exists():
return 0
try:
return sum(1 for _ in d.glob("*.json"))
except Exception:
return 0
# ---------------------------------------------------------------------------
# Write origin
# ---------------------------------------------------------------------------
def current_origin() -> str:
"""Return the active write origin: ``foreground`` or ``background_review``.
Reuses the skill-provenance ContextVar, which the background review fork
already sets (see ``agent.background_review`` /
``AIAgent._spawn_background_review``). Foreground agent turns leave it at
the default ``foreground``.
"""
try:
from tools.skill_provenance import get_current_write_origin
return get_current_write_origin()
except Exception:
return "foreground"
def is_background() -> bool:
return current_origin() == "background_review"
# ---------------------------------------------------------------------------
# Gate decision
# ---------------------------------------------------------------------------
class GateDecision:
"""Result of evaluating the write gate for a single write attempt.
Exactly one of the boolean flags is True:
* ``allow`` proceed with the real write (gate off, or an inline
approval was granted).
* ``blocked`` refuse the write (the user denied an inline approval
prompt). ``message`` explains why; surface it to the agent.
* ``stage`` do not write; the caller should stage the payload via
``stage_write`` (gate on, and no inline prompt is available gateway,
background review, script, or any skill write). ``message`` is the
user-facing "staged for approval" note.
"""
__slots__ = ("allow", "blocked", "stage", "message")
def __init__(self, *, allow=False, blocked=False, stage=False, message=""):
self.allow = allow
self.blocked = blocked
self.stage = stage
self.message = message
def evaluate_gate(subsystem: str, *, inline_summary: str = "",
inline_detail: str = "") -> GateDecision:
"""Decide what to do with a pending write for ``subsystem``.
Args:
subsystem: ``memory`` or ``skills``.
inline_summary: short description used as the inline approval prompt
header (memory foreground path only).
inline_detail: full content shown in the inline prompt (memory entries
are small; skills never take the inline path).
Decision matrix:
gate off (default) allow (writes flow freely)
gate on, memory + interactive CLI inline approve/deny prompt
gate on, memory + gateway/script/bg stage
gate on, skills (any origin) stage (too big to review inline)
Note: there is no config-driven "blocked" outcome the gate only ever
delays a write for approval, never silently refuses it. ``blocked`` is
still produced when the user *actively denies* an inline prompt.
"""
if not write_approval_enabled(subsystem):
return GateDecision(allow=True)
background = is_background()
# Skills always stage — a SKILL.md is too large to review inline, and a
# background skill write happens in a daemon thread with no user present.
if subsystem == SKILLS or background:
where = "/skills pending" if subsystem == SKILLS else "/memory pending"
return GateDecision(
stage=True,
message=(
f"Staged for approval ({subsystem}.write_approval is on). "
f"Not yet saved — review with {where}."
),
)
# Memory + foreground: if an interactive approval channel exists (a CLI
# approval callback registered on this thread), prompt inline — entries
# are small enough to show in full. Otherwise (gateway, script, batch,
# no listener) stage instead of forcing a blind deny.
if _interactive_approval_available():
granted = _prompt_inline_memory_approval(inline_summary, inline_detail)
if granted is True:
return GateDecision(allow=True)
if granted is False:
return GateDecision(
blocked=True,
message="Memory write denied by user. The change was not saved.",
)
# granted is None → prompt failed; fall through to staging.
return GateDecision(
stage=True,
message=(
"Staged for approval (memory.write_approval is on). "
"Not yet saved — review with /memory pending."
),
)
def _interactive_approval_available() -> bool:
"""True when a foreground memory write can be approved inline.
Inline prompting requires a per-thread approval callback registered by the
interactive CLI (``tools.terminal_tool.set_approval_callback``). Every
other surface stages instead:
* **Gateway/API sessions** the dangerous-command ``/approve`` round-trip
lives in the pending-approval queue (``submit_pending`` +
``_await_gateway_decision``), which ``prompt_dangerous_approval`` never
reaches; trying to prompt from a gateway session would hit the
``input()`` fallback and silently deny. Staging gives the user a real
review affordance (``/memory pending``) instead.
* Scripts, cron, and background threads no user present.
"""
try:
from tools.terminal_tool import _get_approval_callback
return _get_approval_callback() is not None
except Exception:
return False
def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:
"""Prompt the user inline to approve a memory write.
Returns True (approved), False (denied), or None (no interactive prompt
available / prompt failed caller should stage instead).
Reuses the per-thread CLI approval callback registered for dangerous
commands (``tools.terminal_tool.set_approval_callback``). The callback is
invoked directly NOT via ``prompt_dangerous_approval`` because that
wrapper falls back to ``input()`` (deadlock-prone under prompt_toolkit,
see #15216) and converts callback errors into a silent deny; here a
failed prompt must stage the write instead.
"""
try:
from tools.terminal_tool import _get_approval_callback
except Exception:
return None
callback = _get_approval_callback()
if callback is None:
# No interactive channel on this thread — stage rather than risk the
# input() fallback (deadlock under prompt_toolkit, EOF-deny in tests).
return None
header = summary.strip() or "Save to memory?"
body = detail.strip()
description = f"Save to memory: {header}"
command = body if body else header
# Invoke the callback directly instead of via prompt_dangerous_approval:
# that wrapper swallows callback exceptions into "deny", which would
# silently refuse the write. Direct invocation lets a crashed prompt fall
# back to staging (the gate only ever delays a write, never drops it).
try:
choice = callback(command, description, allow_permanent=False)
except Exception as e:
logger.error("Inline memory approval prompt failed: %s", e)
return None
if choice in {"once", "session"}:
return True
if choice == "deny":
return False
# Any other outcome (e.g. timeout that returns "deny" already handled) →
# treat unknown as no-decision so we stage rather than silently drop.
return None
# ---------------------------------------------------------------------------
# Skill-specific helpers (gist + diff for the review affordances)
# ---------------------------------------------------------------------------
def skill_gist(action: str, name: str, *, content: str = "",
file_path: str = "", old_string: str = "",
new_string: str = "") -> str:
"""Build a one-line human gist for a pending skill write.
Heuristic, no model call the gist surfaces enough to decide approve/reject
in a chat bubble, while the full diff stays behind /skills diff (CLI/
dashboard/file). For create/edit it pulls the frontmatter ``description:``;
for patch/write_file it describes the size of the change.
"""
if action in {"create", "edit"} and content:
desc = _frontmatter_description(content)
size = f"{len(content) // 1024 + 1} KB" if len(content) >= 1024 else f"{len(content)} chars"
verb = "create" if action == "create" else "rewrite"
if desc:
return f"{verb} '{name}'{desc} ({size})"
return f"{verb} '{name}' ({size})"
if action == "patch":
target = file_path or "SKILL.md"
removed = old_string.count("\n") + 1 if old_string else 0
added = new_string.count("\n") + 1 if new_string else 0
return f"patch '{name}' {target} (+{added}/-{removed} lines)"
if action == "write_file":
return f"write {file_path} in '{name}'"
if action == "remove_file":
return f"remove {file_path} from '{name}'"
if action == "delete":
return f"delete skill '{name}'"
return f"{action} '{name}'"
def _frontmatter_description(content: str) -> str:
"""Extract the ``description:`` value from SKILL.md YAML frontmatter."""
import re
m = re.search(r"^description:\s*(.+)$", content, re.MULTILINE)
if not m:
return ""
desc = m.group(1).strip().strip("'\"")
return desc[:140]
def skill_pending_diff(record: Dict[str, Any]) -> str:
"""Build a full unified diff (or full content) for a staged skill write.
Used by /skills diff <id> on a surface that can render it (CLI pager, web
dashboard, or by opening the pending JSON file). For create this is the new
file content; for edit/patch it is a unified diff against the current
on-disk skill.
"""
import difflib
payload = record.get("payload", {})
action = payload.get("action", "")
name = payload.get("name", "")
if action == "create":
return (payload.get("content") or "")
# Resolve current on-disk content for diffable actions.
try:
from tools.skill_manager_tool import _find_skill
except Exception:
_find_skill = None # type: ignore
current = ""
target_label = "SKILL.md"
if _find_skill is not None:
found = _find_skill(name)
if found:
base = found["path"]
if action == "edit":
p = base / "SKILL.md"
elif action in {"patch", "write_file"}:
rel = payload.get("file_path") or "SKILL.md"
p = base / rel
target_label = rel
else:
p = base / "SKILL.md"
try:
if p.exists():
current = p.read_text(encoding="utf-8")
except Exception:
current = ""
if action == "edit":
new = payload.get("content") or ""
elif action == "patch":
old_s = payload.get("old_string") or ""
new_s = payload.get("new_string") or ""
new = current.replace(old_s, new_s) if current else f"(patch {old_s!r}{new_s!r})"
elif action == "write_file":
new = payload.get("file_content") or ""
elif action == "remove_file":
return f"remove file: {payload.get('file_path')} from skill '{name}'"
elif action == "delete":
return f"delete skill '{name}'"
else:
return f"({action} on '{name}')"
diff = difflib.unified_diff(
current.splitlines(keepends=True),
new.splitlines(keepends=True),
fromfile=f"a/{target_label}",
tofile=f"b/{target_label}",
)
text = "".join(diff)
return text or "(no textual change)"