Use tmux for stable Hermes auto-kickoff (avoid PTY freeze)

PTY relay freezes prompt_toolkit after the first turn. Prefer a detached
tmux session, send short kickoff via send-keys, then attach. Keep PTY
as fallback when tmux is missing.
This commit is contained in:
domfelipe 2026-08-03 00:48:01 -03:00
parent b8f55e799a
commit 1ec8930426
3 changed files with 91 additions and 37 deletions

View file

@ -155,10 +155,17 @@ O bootstrap instala `~/.local/bin/hermes-client-onboarding`:
hermes-client-onboarding hermes-client-onboarding
``` ```
Por padrão usa **CLI clássico + injeção automática** da primeira mensagem (PTY). O TUI nativo do Hermes tem race no startup-query (~4s) e costuma ficar mudo — por isso não é o default. Por padrão usa **tmux** (Hermes em sessão dedicada + `send-keys` do kickoff). Isso evita o freeze do wrapper PTY com prompt_toolkit.
```bash ```bash
# TUI (opcional, menos confiável para auto-start) # se pedir tmux e não tiver:
# apt install -y tmux
hermes-client-onboarding
# detach: Ctrl-b d
# reattach: tmux ls && tmux attach -t hermes-onboard-<pid>
# TUI Ink (opcional, kickoff frágil)
HERMES_ONBOARD_USE_TUI=1 hermes-client-onboarding HERMES_ONBOARD_USE_TUI=1 hermes-client-onboarding
``` ```

View file

@ -1,31 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Spawn hermes chat --cli -s <skill> and submit a short kickoff once. """Fallback: spawn hermes chat --cli and inject a short kickoff via PTY.
Avoids Hermes paste-collapse (5 lines or 2000 chars [Pasted text #N]). Prefer tmux path in start-onboarding.sh this can freeze prompt_toolkit
Uses raw TTY relay so Enter stays inside Hermes, not the outer shell. on some terminals. Kept for hosts without tmux.
""" """
from __future__ import annotations from __future__ import annotations
import fcntl
import os import os
import pty import pty
import select import select
import signal
import struct
import sys import sys
import termios import termios
import time import time
import tty import tty
# Keep under paste_collapse thresholds (5 lines / 2000 chars)
DEFAULT_KICKOFF = ( DEFAULT_KICKOFF = (
"Inicie o onboarding agora. Skill hermes-client-onboarding. " "Inicie o onboarding agora. Skill hermes-client-onboarding. "
"Pre-flight silencioso e abra a Phase 1 (voce fala primeiro)." "Pre-flight silencioso e Phase 1 (voce fala primeiro)."
) )
def _set_winsize(fd: int) -> None:
try:
import shutil
cols, rows = shutil.get_terminal_size(fallback=(120, 40))
packed = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)
except Exception:
pass
def main() -> int: def main() -> int:
skill = os.environ.get("HERMES_ONBOARD_SKILL", "hermes-client-onboarding") skill = os.environ.get("HERMES_ONBOARD_SKILL", "hermes-client-onboarding")
kickoff = os.environ.get("HERMES_ONBOARD_KICKOFF", DEFAULT_KICKOFF).strip() kickoff = os.environ.get("HERMES_ONBOARD_KICKOFF", DEFAULT_KICKOFF).strip()
# Collapse accidental newlines so we never trip paste_collapse by lines
kickoff = " ".join(kickoff.split()) kickoff = " ".join(kickoff.split())
if len(kickoff) > 400: if len(kickoff) > 400:
kickoff = kickoff[:397] + "..." kickoff = kickoff[:397] + "..."
@ -36,9 +48,23 @@ def main() -> int:
pid, master = pty.fork() pid, master = pty.fork()
if pid == 0: if pid == 0:
os.environ.pop("HERMES_TUI_QUERY", None) # avoid confusing classic CLI os.environ.pop("HERMES_TUI_QUERY", None)
os.execvp(argv[0], argv) os.execvp(argv[0], argv)
_set_winsize(master)
def _on_winch(_sig: int, _frame: object) -> None:
_set_winsize(master)
try:
os.kill(pid, signal.SIGWINCH)
except ProcessLookupError:
pass
try:
signal.signal(signal.SIGWINCH, _on_winch)
except Exception:
pass
stdin_fd = sys.stdin.fileno() stdin_fd = sys.stdin.fileno()
stdout_fd = sys.stdout.fileno() stdout_fd = sys.stdout.fileno()
old_tty = None old_tty = None
@ -49,10 +75,9 @@ def main() -> int:
sent = False sent = False
buf = b"" buf = b""
start = time.time() start = time.time()
# Wait for skill activation line, else inject after a few seconds
try: try:
while True: while True:
r, _, _ = select.select([master, stdin_fd], [], [], 0.15) r, _, _ = select.select([master, stdin_fd], [], [], 0.12)
now = time.time() now = time.time()
if master in r: if master in r:
@ -77,23 +102,22 @@ def main() -> int:
if not sent: if not sent:
lower = buf.lower() lower = buf.lower()
activated = b"activated skills" in lower or b"hermes-client-onboarding" in lower ready = (
timed = now >= start + 3.5 b"activated skills" in lower
if (activated and now >= start + 1.2) or timed: or b"welcome to hermes" in lower
time.sleep(0.25) or b"type your message" in lower
# Type as normal keys + CR (not a giant paste burst) )
payload = (kickoff + "\r").encode("utf-8", errors="replace") if (ready and now >= start + 1.0) or now >= start + 4.0:
os.write(master, payload) time.sleep(0.35)
os.write(master, (kickoff + "\r").encode("utf-8", errors="replace"))
sent = True sent = True
wpid, status = os.waitpid(pid, os.WNOHANG) wpid, status = os.waitpid(pid, os.WNOHANG)
if wpid == pid: if wpid == pid:
if os.WIFEXITED(status): return os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1
return os.WEXITSTATUS(status)
return 1
except KeyboardInterrupt: except KeyboardInterrupt:
try: try:
os.kill(pid, 2) os.kill(pid, signal.SIGINT)
except ProcessLookupError: except ProcessLookupError:
pass pass
return 130 return 130

View file

@ -1,20 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Launch Hermes with hermes-client-onboarding; agent speaks first (Phase 1). # Launch Hermes with hermes-client-onboarding; agent speaks first (Phase 1).
# #
# Default: classic CLI + PTY auto-kickoff (reliable). # Preferred: tmux session + send-keys (stable full-screen Hermes, no PTY freeze).
# Optional: HERMES_ONBOARD_USE_TUI=1 for Ink TUI (env HERMES_TUI_QUERY; may race). # Fallback: Python PTY inject (can be flaky with prompt_toolkit).
# Optional: HERMES_ONBOARD_USE_TUI=1 for Ink TUI (startup-query race).
set -euo pipefail set -euo pipefail
export PATH="${HOME}/.local/bin:/usr/local/bin:${PATH}" export PATH="${HOME}/.local/bin:/usr/local/bin:${PATH}"
SKILL_NAME="${HERMES_ONBOARD_SKILL:-hermes-client-onboarding}" SKILL_NAME="${HERMES_ONBOARD_SKILL:-hermes-client-onboarding}"
# Short on purpose: long kickoffs hit Hermes paste-collapse (≥5 lines / 2000 chars) # Short: long text trips Hermes paste-collapse
# and leave a stuck [Pasted text #N] instead of submitting.
KICKOFF="${HERMES_ONBOARD_KICKOFF:-Inicie o onboarding agora. Skill hermes-client-onboarding. Pre-flight silencioso e Phase 1 (voce fala primeiro).}" KICKOFF="${HERMES_ONBOARD_KICKOFF:-Inicie o onboarding agora. Skill hermes-client-onboarding. Pre-flight silencioso e Phase 1 (voce fala primeiro).}"
KICKOFF="$(printf '%s' "$KICKOFF" | tr '\n' ' ' | sed 's/ */ /g')"
export HERMES_ONBOARD_SKILL="$SKILL_NAME" export HERMES_ONBOARD_SKILL="$SKILL_NAME"
export HERMES_ONBOARD_KICKOFF="$KICKOFF" export HERMES_ONBOARD_KICKOFF="$KICKOFF"
# TUI path (also set so HERMES_TUI=1 launches pick up kickoff)
export HERMES_TUI_SKILLS="$SKILL_NAME" export HERMES_TUI_SKILLS="$SKILL_NAME"
export HERMES_TUI_QUERY="$KICKOFF" export HERMES_TUI_QUERY="$KICKOFF"
@ -24,37 +24,60 @@ if ! command -v hermes >/dev/null 2>&1; then
fi fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# When installed as ~/.local/bin/hermes-client-onboarding, companion lives with skill
AUTO_PY="" AUTO_PY=""
for candidate in \ for candidate in \
"${SCRIPT_DIR}/auto_kickoff_cli.py" \ "${SCRIPT_DIR}/auto_kickoff_cli.py" \
"${HOME}/.hermes/skills/${SKILL_NAME}/scripts/auto_kickoff_cli.py" \ "${HOME}/.hermes/skills/${SKILL_NAME}/scripts/auto_kickoff_cli.py" \
"${HOME}/.local/share/hermes-client-onboarding/auto_kickoff_cli.py" "${HOME}/.local/share/hermes-client-onboarding/auto_kickoff_cli.py"
do do
if [[ -f "$candidate" ]]; then [[ -f "$candidate" ]] && AUTO_PY="$candidate" && break
AUTO_PY="$candidate"
break
fi
done done
use_tui="${HERMES_ONBOARD_USE_TUI:-0}" use_tui="${HERMES_ONBOARD_USE_TUI:-0}"
if [[ "$use_tui" == "1" ]]; then if [[ "$use_tui" == "1" ]]; then
# Explicit env + --query (TUI maps -q → HERMES_TUI_QUERY; keep both)
if [[ -r /dev/tty ]]; then if [[ -r /dev/tty ]]; then
exec hermes chat --tui -s "$SKILL_NAME" --query "$KICKOFF" </dev/tty exec hermes chat --tui -s "$SKILL_NAME" --query "$KICKOFF" </dev/tty
fi fi
exec hermes chat --tui -s "$SKILL_NAME" --query "$KICKOFF" exec hermes chat --tui -s "$SKILL_NAME" --query "$KICKOFF"
fi fi
# Reliable path: classic CLI + inject first user message # --- Preferred: tmux (no frozen PTY wrapper) ---
if command -v tmux >/dev/null 2>&1 && [[ -t 0 && -t 1 ]]; then
SESSION="hermes-onboard-$$"
# Kill leftover same-name (shouldn't happen with $$)
tmux has-session -t "$SESSION" 2>/dev/null && tmux kill-session -t "$SESSION" 2>/dev/null || true
tmux new-session -d -s "$SESSION" -x "$(tput cols 2>/dev/null || echo 120)" -y "$(tput lines 2>/dev/null || echo 40)" \
"export PATH=\"${PATH}\"; hermes chat --cli -s ${SKILL_NAME}; exec bash"
# Wait until Hermes is up, then type kickoff + Enter
for i in $(seq 1 40); do
# capture pane; look for skill activation or welcome
pane="$(tmux capture-pane -t "$SESSION" -p 2>/dev/null || true)"
if printf '%s' "$pane" | grep -qiE 'Activated skills|Welcome to Hermes|hermes-client-onboarding'; then
sleep 0.6
break
fi
sleep 0.25
done
sleep 0.4
# send-keys: literal string then Enter (C-m)
tmux send-keys -t "$SESSION" -l -- "$KICKOFF"
sleep 0.15
tmux send-keys -t "$SESSION" C-m
echo "==> Sessão tmux: $SESSION (detach: Ctrl-b d | reattach: tmux attach -t $SESSION)"
exec tmux attach -t "$SESSION"
fi
# --- Fallback: PTY inject ---
if [[ -n "$AUTO_PY" ]] && command -v python3 >/dev/null 2>&1; then if [[ -n "$AUTO_PY" ]] && command -v python3 >/dev/null 2>&1; then
echo "warn: tmux not found — using PTY fallback (se travar, instale: apt install -y tmux)" >&2
if [[ -r /dev/tty ]]; then if [[ -r /dev/tty ]]; then
exec python3 "$AUTO_PY" "$@" </dev/tty >/dev/tty 2>/dev/tty exec python3 "$AUTO_PY" "$@" </dev/tty >/dev/tty 2>/dev/tty
fi fi
exec python3 "$AUTO_PY" "$@" exec python3 "$AUTO_PY" "$@"
fi fi
# Last resort: one-shot (not interactive after) echo "warn: no tmux/python auto-kickoff — one-shot only" >&2
echo "warn: auto_kickoff_cli.py missing — running one-shot kickoff only" >&2
exec hermes chat -s "$SKILL_NAME" -Q -q "$KICKOFF" "$@" exec hermes chat -s "$SKILL_NAME" -Q -q "$KICKOFF" "$@"