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
+128 -72
View File
@@ -19,6 +19,7 @@ import shutil
import subprocess
import sys
import threading
from contextlib import contextmanager
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
@@ -165,7 +166,7 @@ _parallel_pool_max_workers: Optional[int] = None
_running_job_ids: set = set()
_running_lock = threading.Lock()
# Sequential (env-mutating) cron jobs — workdir jobs that touch
# Sequential (env/context-mutating) cron jobs — workdir/profile jobs that touch
# process-global runtime state — must run one at a time, but must NOT block the
# ticker thread. A persistent single-thread executor preserves ordering across
# ticks while keeping dispatch fire-and-forget, the same as the parallel pool.
@@ -189,10 +190,10 @@ def _get_parallel_pool(max_workers: Optional[int]) -> concurrent.futures.ThreadP
def _get_sequential_pool() -> concurrent.futures.ThreadPoolExecutor:
"""Return (or create) the persistent single-thread sequential pool.
A single worker guarantees env-mutating jobs never overlap, even
A single worker guarantees env/context-mutating jobs never overlap, even
across ticks: a job queued by a newer tick waits for the previous tick's
sequential jobs to finish rather than corrupting their os.environ
state.
sequential jobs to finish rather than corrupting their os.environ /
profile state.
"""
global _sequential_pool
if _sequential_pool is None:
@@ -234,6 +235,71 @@ def _get_lock_paths() -> tuple[Path, Path]:
return lock_dir, lock_dir / ".tick.lock"
@contextmanager
def _job_profile_context(job_id: str, profile: Optional[str]):
"""Temporarily run a job under a specific Hermes profile.
Cron jobs are stored and scheduled by the profile running the scheduler, but
an individual job can opt into a different runtime profile. While active,
the scheduler's test/override hook and a context-local Hermes home override
both point at the resolved profile directory so _get_hermes_home(),
.env/config loading, script resolution, AIAgent construction, and downstream
get_hermes_home() callers agree on the same home.
Some existing provider/config paths still load profile .env values through
os.environ, so profile jobs also snapshot and restore the process
environment on exit. tick() runs profile jobs sequentially to keep that
temporary mutation isolated from other scheduled jobs.
"""
raw_profile = str(profile or "").strip()
if not raw_profile:
yield None
return
global _hermes_home
prior_override = _hermes_home
env_snapshot = os.environ.copy()
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
normalized_profile = normalize_profile_name(raw_profile)
try:
profile_home = Path(resolve_profile_env(normalized_profile)).resolve()
except (FileNotFoundError, ValueError) as exc:
logger.warning(
"Job '%s': configured profile %r no longer valid (%s) — "
"falling back to scheduler default",
job_id, raw_profile, exc,
)
yield None
return
override_token = None
try:
override_token = set_hermes_home_override(profile_home)
_hermes_home = profile_home
logger.info(
"Job '%s': using Hermes profile '%s' (%s)",
job_id,
normalized_profile,
profile_home,
)
yield normalized_profile
finally:
_hermes_home = prior_override
if override_token is not None:
reset_hermes_home_override(override_token)
# Delta-based restore: remove added keys, restore changed keys.
# Avoids a brief window where other threads see an empty env.
added = set(os.environ.keys()) - set(env_snapshot.keys())
for k in added:
os.environ.pop(k, None)
for k, v in env_snapshot.items():
if os.environ.get(k) != v:
os.environ[k] = v
def _resolve_origin(job: dict) -> Optional[dict]:
"""Extract origin info from a job, preserving any extra routing metadata.
@@ -966,6 +1032,17 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
else:
argv = [sys.executable, str(path)]
run_env = os.environ.copy()
run_env["HERMES_HOME"] = str(_get_hermes_home())
try:
from hermes_constants import get_subprocess_home
profile_home = get_subprocess_home()
if profile_home:
run_env["HOME"] = profile_home
except Exception:
pass
try:
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
result = subprocess.run(
@@ -974,6 +1051,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
text=True,
timeout=script_timeout,
cwd=str(path.parent),
env=run_env,
**popen_kwargs,
)
stdout = (result.stdout or "").strip()
@@ -1040,15 +1118,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
result is used for prompt injection. When omitted, the script
(if any) runs inline as before.
"""
user_prompt = str(job.get("prompt") or "")
prompt = user_prompt
prompt = str(job.get("prompt") or "")
skills = job.get("skills")
# True when runtime-collected DATA (script stdout, upstream-job output)
# has been injected into the prompt. Data content legitimately quotes
# command-shape strings (a triage feed ingesting a bug report that
# pastes `rm -rf /`), so it must not be scanned with the strict
# user-prompt pattern set — see _scan_assembled_cron_prompt.
has_injected_data = False
# Run data-collection script if configured, inject output as context.
script_path = job.get("script")
@@ -1066,7 +1137,6 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
has_injected_data = True
else:
# Script produced no output — nothing to report, skip AI call.
return None
@@ -1077,7 +1147,6 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
has_injected_data = True
# Inject output from referenced cron jobs as context.
context_from = job.get("context_from")
@@ -1120,7 +1189,6 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
f"```\n{latest_output}\n```\n\n"
f"{prompt}"
)
has_injected_data = True
else:
continue # silent skip — empty output
except (OSError, PermissionError) as e:
@@ -1149,13 +1217,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
skill_names = [str(name).strip() for name in skills if str(name).strip()]
if not skill_names:
return _scan_assembled_cron_prompt(
prompt,
job,
has_skills=False,
has_injected_data=has_injected_data,
user_prompt=user_prompt,
)
return _scan_assembled_cron_prompt(prompt, job, has_skills=False)
from tools.skills_tool import skill_view
from tools.skill_usage import bump_use
@@ -1232,14 +1294,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
return _scan_assembled_cron_prompt("\n".join(parts), job, has_skills=True)
def _scan_assembled_cron_prompt(
assembled: str,
job: dict,
*,
has_skills: bool = False,
has_injected_data: bool = False,
user_prompt: Optional[str] = None,
) -> str:
def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool = False) -> str:
"""Scan the fully-assembled cron prompt for injection patterns. Raises
``CronPromptInjectionBlocked`` when a match fires so ``run_job`` can
surface a clear refusal to the operator.
@@ -1250,45 +1305,29 @@ def _scan_assembled_cron_prompt(
(auto-approves tool calls), a malicious skill carrying an injection
payload bypassed every gate.
Two pattern tiers, selected by what the assembled prompt CONTAINS,
not just whether skills are attached:
Two pattern tiers:
- When the assembled prompt is essentially the user prompt + the cron
hint (no skills, no injected data), the STRICT ``_scan_cron_prompt``
patterns apply: a bare ``rm -rf /`` in a small directive prompt is a
smoking gun, not prose.
- When the assembled prompt includes runtime-loaded content — skill
markdown (``has_skills=True``) or DATA injected from a job script's
stdout / an upstream job's output (``has_injected_data=True``) — the
LOOSER ``_scan_cron_skill_assembled`` pattern set is used: only
unambiguous prompt-injection directives block; command-shape
patterns are dropped and invisible unicode is sanitized (stripped +
logged) rather than blocked, to avoid false-positives that
permanently kill a job. Skill bodies are vetted at install time by
``skills_guard.py``; script output is produced by operator-authored
code, the same trust class — and data feeds (e.g. a triage bot
ingesting bug reports) legitimately quote dangerous commands.
When the looser tier is selected because of injected data only,
``user_prompt`` (the raw, pre-assembly prompt) is additionally scanned
with the STRICT set so the user-authored surface keeps the full
create/update-time guarantee at runtime (defense-in-depth for legacy
jobs that predate the create-time scanner).
- When ``has_skills=False`` (no skills attached) the assembled prompt
is essentially the user prompt + the cron hint, so the STRICT
``_scan_cron_prompt`` patterns apply.
- When ``has_skills=True`` the assembled prompt includes loaded skill
markdown — often security docs / runbooks that *describe* attack
commands in prose. The LOOSER ``_scan_cron_skill_assembled``
pattern set is used: only unambiguous prompt-injection directives
block; command-shape patterns are dropped and invisible unicode is
sanitized (stripped + logged) rather than blocked, to avoid
false-positives that permanently kill a job. Skill bodies are
vetted at install time by ``skills_guard.py``.
"""
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
if has_skills or has_injected_data:
# Runtime-loaded content (vetted skill markdown and/or data from
# operator-authored scripts) legitimately contains command-shape
# strings. Invisible unicode is sanitized (not blocked) so a stray
# zero-width space can't permanently kill the job; the cleaned
if has_skills:
# Skill content is install-time vetted by skills_guard.py. Invisible
# unicode is sanitized (not blocked) so a stray zero-width space in a
# skill code example can't permanently kill the job; the cleaned
# prompt is what actually runs.
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
assembled = cleaned
if not scan_error and not has_skills and user_prompt:
# Data-injection path: keep the strict guarantee on the
# user-authored prompt itself.
scan_error = _scan_cron_prompt(user_prompt)
else:
scan_error = _scan_cron_prompt(assembled)
if scan_error:
@@ -1303,6 +1342,13 @@ def _scan_assembled_cron_prompt(
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""Execute a single cron job, applying any per-job profile override."""
job_id = job["id"]
with _job_profile_context(job_id, job.get("profile")):
return _run_job_impl(job)
def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""
Execute a single cron job.
@@ -1539,8 +1585,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# .cursorrules from the job's project dir, AND
# - the terminal, file, and code-exec tools run commands from there.
#
# tick() serializes workdir-jobs outside the parallel pool, so mutating
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
# tick() serializes jobs that mutate process-global runtime state (workdir
# and/or profile jobs) outside the parallel pool, so mutating
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
# jobs we leave TERMINAL_CWD untouched — preserves the original behaviour
# (skip_context_files=True, tools use whatever cwd the scheduler has).
_job_workdir = (job.get("workdir") or "").strip() or None
@@ -2087,12 +2134,21 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
mark_job_run(job["id"], False, str(e))
return False
# Partition due jobs: those with a per-job workdir mutate
# os.environ["TERMINAL_CWD"] inside run_job, which is process-global —
# so they MUST run sequentially to avoid corrupting each other. Jobs
# without a workdir leave env untouched and stay parallel-safe.
sequential_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()]
parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()]
# Partition due jobs: jobs with a per-job workdir and/or profile touch
# process-global runtime state inside run_job. Workdir jobs temporarily
# set os.environ["TERMINAL_CWD"]; profile jobs use a context-local
# Hermes home override, scheduler _hermes_home hook, and temporary
# profile .env load into os.environ with snapshot/restore. They MUST run
# sequentially to avoid corrupting each other. Jobs without either field
# stay parallel-safe.
sequential_jobs = [
j for j in due_jobs
if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
]
parallel_jobs = [
j for j in due_jobs
if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
]
_results: list = []
_all_futures: list = []
@@ -2121,9 +2177,9 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
return pool.submit(_run_and_release)
# Sequential pass for env-mutating (workdir) jobs.
# Sequential pass for env/context-mutating (workdir/profile) jobs.
# Queued to a persistent single-thread pool so they run one at a time
# WITHOUT blocking the ticker thread — a long workdir job no
# WITHOUT blocking the ticker thread — a long workdir/profile job no
# longer starves the rest of the schedule (same fix as the parallel
# pass, just serialized). The in-flight guard prevents a still-running
# job from being re-queued on the next tick.