Reliable auto-kickoff via classic CLI PTY inject

TUI HERMES_TUI_QUERY races session creation (~4s) and often never
submits. Default launcher now uses hermes chat --cli plus a small
Python PTY that injects the Phase-1 kickoff after ready markers.
This commit is contained in:
domfelipe 2026-08-03 00:26:43 -03:00
parent 29c3fa703b
commit d8bea2017b
5 changed files with 204 additions and 23 deletions

View file

@ -149,14 +149,18 @@ Não imprime secrets.
## Launcher (agente fala primeiro) ## Launcher (agente fala primeiro)
O bootstrap instala `~/.local/bin/hermes-client-onboarding`, que abre o TUI com kickoff automático: O bootstrap instala `~/.local/bin/hermes-client-onboarding`:
```bash ```bash
hermes-client-onboarding hermes-client-onboarding
``` ```
Isso usa `hermes chat --tui -s hermes-client-onboarding -q "…"` — o Hermes **envia a primeira mensagem sozinho** e continua interativo. 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.
`hermes chat -s …` sem `-q` / sem launcher espera o usuário falar primeiro.
```bash
# TUI (opcional, menos confiável para auto-start)
HERMES_ONBOARD_USE_TUI=1 hermes-client-onboarding
```
## Licença ## Licença

View file

@ -107,6 +107,9 @@ copy_tree() {
if [[ -f "$dest/scripts/start-onboarding.sh" ]]; then if [[ -f "$dest/scripts/start-onboarding.sh" ]]; then
chmod +x "$dest/scripts/start-onboarding.sh" chmod +x "$dest/scripts/start-onboarding.sh"
fi fi
if [[ -f "$dest/scripts/auto_kickoff_cli.py" ]]; then
chmod +x "$dest/scripts/auto_kickoff_cli.py"
fi
} }
fetch_skill_to() { fetch_skill_to() {
@ -119,8 +122,10 @@ fetch_skill_to() {
curl -fsSL "${base}/skill/${SKILL_NAME}/references/troubleshooting.md" -o "$dest/references/troubleshooting.md" curl -fsSL "${base}/skill/${SKILL_NAME}/references/troubleshooting.md" -o "$dest/references/troubleshooting.md"
curl -fsSL "${base}/skill/${SKILL_NAME}/scripts/apply-core-config.sh" -o "$dest/scripts/apply-core-config.sh" curl -fsSL "${base}/skill/${SKILL_NAME}/scripts/apply-core-config.sh" -o "$dest/scripts/apply-core-config.sh"
curl -fsSL "${base}/skill/${SKILL_NAME}/scripts/start-onboarding.sh" -o "$dest/scripts/start-onboarding.sh" || true curl -fsSL "${base}/skill/${SKILL_NAME}/scripts/start-onboarding.sh" -o "$dest/scripts/start-onboarding.sh" || true
curl -fsSL "${base}/skill/${SKILL_NAME}/scripts/auto_kickoff_cli.py" -o "$dest/scripts/auto_kickoff_cli.py" || true
chmod +x "$dest/scripts/apply-core-config.sh" chmod +x "$dest/scripts/apply-core-config.sh"
[[ -f "$dest/scripts/start-onboarding.sh" ]] && chmod +x "$dest/scripts/start-onboarding.sh" [[ -f "$dest/scripts/start-onboarding.sh" ]] && chmod +x "$dest/scripts/start-onboarding.sh"
[[ -f "$dest/scripts/auto_kickoff_cli.py" ]] && chmod +x "$dest/scripts/auto_kickoff_cli.py"
[[ -s "$dest/SKILL.md" ]] || die "failed to download SKILL.md from $base" [[ -s "$dest/SKILL.md" ]] || die "failed to download SKILL.md from $base"
} }
@ -142,13 +147,18 @@ install_skill() {
copy_tree "$staging" "$hermes_dest" copy_tree "$staging" "$hermes_dest"
log "Skill installed for Hermes → $hermes_dest" log "Skill installed for Hermes → $hermes_dest"
# Launcher: auto-starts Phase 1 (agent speaks first) # Launcher: auto-starts Phase 1 (agent speaks first via CLI PTY inject)
mkdir -p "${HOME}/.local/bin" mkdir -p "${HOME}/.local/bin" "${HOME}/.local/share/hermes-client-onboarding"
if [[ -f "$staging/scripts/start-onboarding.sh" ]]; then if [[ -f "$staging/scripts/start-onboarding.sh" ]]; then
cp -f "$staging/scripts/start-onboarding.sh" "${HOME}/.local/bin/hermes-client-onboarding" cp -f "$staging/scripts/start-onboarding.sh" "${HOME}/.local/bin/hermes-client-onboarding"
chmod +x "${HOME}/.local/bin/hermes-client-onboarding" chmod +x "${HOME}/.local/bin/hermes-client-onboarding"
log "Launcher → ~/.local/bin/hermes-client-onboarding (agent fala primeiro)" log "Launcher → ~/.local/bin/hermes-client-onboarding (agent fala primeiro)"
fi fi
if [[ -f "$staging/scripts/auto_kickoff_cli.py" ]]; then
cp -f "$staging/scripts/auto_kickoff_cli.py" "${HOME}/.local/share/hermes-client-onboarding/auto_kickoff_cli.py"
cp -f "$staging/scripts/auto_kickoff_cli.py" "${HOME}/.hermes/skills/${SKILL_NAME}/scripts/auto_kickoff_cli.py"
chmod +x "${HOME}/.local/share/hermes-client-onboarding/auto_kickoff_cli.py"
fi
# Codex / agents (optional) # Codex / agents (optional)
if [[ -d "${HOME}/.codex" ]] || need_cmd codex; then if [[ -d "${HOME}/.codex" ]] || need_cmd codex; then
@ -210,21 +220,26 @@ pick_conductor() {
} }
launch_hermes_onboarding() { launch_hermes_onboarding() {
# Auto-start: TUI + -q submits first turn; session stays interactive.
export HERMES_ONBOARD_KICKOFF="$KICKOFF_MSG" export HERMES_ONBOARD_KICKOFF="$KICKOFF_MSG"
export HERMES_ONBOARD_SKILL="$SKILL_NAME"
export HERMES_TUI_QUERY="$KICKOFF_MSG"
export HERMES_TUI_SKILLS="$SKILL_NAME"
if [[ -x "${HOME}/.local/bin/hermes-client-onboarding" ]]; then if [[ -x "${HOME}/.local/bin/hermes-client-onboarding" ]]; then
if [[ -r /dev/tty ]]; then if [[ -r /dev/tty ]]; then
exec "${HOME}/.local/bin/hermes-client-onboarding" </dev/tty exec "${HOME}/.local/bin/hermes-client-onboarding" </dev/tty >/dev/tty 2>/dev/tty
else else
exec "${HOME}/.local/bin/hermes-client-onboarding" exec "${HOME}/.local/bin/hermes-client-onboarding"
fi fi
fi fi
# Fallback without launcher binary (-q only works on `hermes chat`) # Fallback: classic CLI + PTY inject script if present
if [[ -r /dev/tty ]]; then local auto_py="${HOME}/.hermes/skills/${SKILL_NAME}/scripts/auto_kickoff_cli.py"
exec hermes chat --tui -s "$SKILL_NAME" -q "$KICKOFF_MSG" </dev/tty if [[ -f "$auto_py" ]] && command -v python3 >/dev/null 2>&1; then
else if [[ -r /dev/tty ]]; then
exec hermes chat --tui -s "$SKILL_NAME" -q "$KICKOFF_MSG" exec python3 "$auto_py" </dev/tty >/dev/tty 2>/dev/tty
fi
exec python3 "$auto_py"
fi fi
die "launcher missing — re-run install or: hermes chat --cli -s ${SKILL_NAME}"
} }
launch_conductor() { launch_conductor() {

View file

@ -78,11 +78,11 @@ Preferred launch (agent auto-starts):
```bash ```bash
hermes-client-onboarding hermes-client-onboarding
# or: # uses classic CLI + auto-kickoff inject (reliable)
hermes chat --tui -s hermes-client-onboarding -q "Inicie AGORA o onboarding…" # TUI (optional): HERMES_ONBOARD_USE_TUI=1 hermes-client-onboarding
``` ```
Plain `hermes chat -s hermes-client-onboarding` without `-q` waits for user input — avoid that for demos. Plain `hermes chat -s hermes-client-onboarding` without kickoff waits for user input — avoid that for demos.
### Phase 1 — Context & Goals ### Phase 1 — Context & Goals

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Spawn `hermes chat --cli -s <skill>` and inject the kickoff as first user message.
Hermes only runs a model turn after a user message. The TUI startup-query path
races session creation (~4s) and often silently skips. This classic-CLI path
waits for a ready prompt, sends the kickoff once, then hands the TTY to the user.
"""
from __future__ import annotations
import os
import pty
import select
import sys
import time
def main() -> int:
skill = os.environ.get("HERMES_ONBOARD_SKILL", "hermes-client-onboarding")
kickoff = os.environ.get(
"HERMES_ONBOARD_KICKOFF",
"Inicie AGORA o onboarding de cliente Hermes. Siga a skill "
"hermes-client-onboarding: pre-flight em silêncio e abra a Phase 1 "
"com a primeira pergunta. Você fala primeiro. Português brasileiro.",
)
if not kickoff.endswith("\n"):
kickoff += "\n"
argv = ["hermes", "chat", "--cli", "-s", skill]
# Extra args after --
if len(sys.argv) > 1:
argv.extend(sys.argv[1:])
pid, master = pty.fork()
if pid == 0:
os.execvp(argv[0], argv)
# Parent: relay I/O; inject kickoff once after session looks ready.
sent = False
buf = b""
start = time.time()
inject_after = 1.5 # min wait for banner
deadline = start + 45.0
try:
while True:
timeout = 0.2
r, _, _ = select.select([master, sys.stdin], [], [], timeout)
now = time.time()
if master in r:
try:
data = os.read(master, 8192)
except OSError:
data = b""
if not data:
break
os.write(sys.stdout.fileno(), data)
buf += data
if len(buf) > 20000:
buf = buf[-10000:]
if sys.stdin in r:
try:
data = os.read(sys.stdin.fileno(), 8192)
except OSError:
data = b""
if not data:
# stdin closed — keep agent until it exits
pass
else:
os.write(master, data)
if not sent and now >= start + inject_after:
lower = buf.lower()
ready_markers = (
b"ready",
b"session:",
b"try ",
b"welcome",
b"type your message",
b"\n> ",
b"\n",
b"\nprompt",
)
looks_ready = any(m in lower for m in ready_markers)
timed = now >= start + 4.0 # hard fallback inject
if looks_ready or timed:
# Small settle so status line finishes drawing
time.sleep(0.35)
os.write(master, kickoff.encode("utf-8", errors="replace"))
sent = True
if not sent and now > deadline:
# Last resort
os.write(master, kickoff.encode("utf-8", errors="replace"))
sent = True
# Reap child
wpid, status = os.waitpid(pid, os.WNOHANG)
if wpid == pid:
if os.WIFEXITED(status):
return os.WEXITSTATUS(status)
return 1
except KeyboardInterrupt:
try:
os.kill(pid, 2)
except ProcessLookupError:
pass
return 130
finally:
try:
os.close(master)
except OSError:
pass
# Blocking wait if loop broke on EOF from master
try:
_, status = os.waitpid(pid, 0)
if os.WIFEXITED(status):
return os.WEXITSTATUS(status)
except ChildProcessError:
pass
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,23 +1,58 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Launch Hermes with hermes-client-onboarding and auto-start Phase 1 # Launch Hermes with hermes-client-onboarding; agent speaks first (Phase 1).
# (agent speaks first — does not wait for the user to say "oi"). #
# Default: classic CLI + PTY auto-kickoff (reliable).
# Optional: HERMES_ONBOARD_USE_TUI=1 for Ink TUI (env HERMES_TUI_QUERY; may 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}"
KICKOFF="${HERMES_ONBOARD_KICKOFF:-Inicie AGORA o onboarding de cliente Hermes. Siga a skill hermes-client-onboarding: execute o pre-flight em silêncio e abra a Phase 1 fazendo a primeira pergunta ao usuário. Você fala primeiro — não espere eu dizer oi ou começar.}" KICKOFF="${HERMES_ONBOARD_KICKOFF:-Inicie AGORA o onboarding de cliente Hermes. Siga a skill hermes-client-onboarding: execute o pre-flight em silêncio e abra a Phase 1 fazendo a primeira pergunta ao usuário. Você fala primeiro — não espere eu dizer oi ou começar. Português brasileiro.}"
export HERMES_ONBOARD_SKILL="$SKILL_NAME"
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_QUERY="$KICKOFF"
if ! command -v hermes >/dev/null 2>&1; then if ! command -v hermes >/dev/null 2>&1; then
echo "error: hermes not on PATH" >&2 echo "error: hermes not on PATH" >&2
exit 1 exit 1
fi fi
# Must use `hermes chat` subcommand: -q is not a top-level flag. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# With --tui, -q becomes HERMES_TUI_QUERY — first turn auto-submits, session stays interactive. # When installed as ~/.local/bin/hermes-client-onboarding, companion lives with skill
if [[ -t 0 && -t 1 ]]; then AUTO_PY=""
exec hermes chat --tui -s "$SKILL_NAME" -q "$KICKOFF" "$@" for candidate in \
"${SCRIPT_DIR}/auto_kickoff_cli.py" \
"${HOME}/.hermes/skills/${SKILL_NAME}/scripts/auto_kickoff_cli.py" \
"${HOME}/.local/share/hermes-client-onboarding/auto_kickoff_cli.py"
do
if [[ -f "$candidate" ]]; then
AUTO_PY="$candidate"
break
fi
done
use_tui="${HERMES_ONBOARD_USE_TUI:-0}"
if [[ "$use_tui" == "1" ]]; then
# Explicit env + --query (TUI maps -q → HERMES_TUI_QUERY; keep both)
if [[ -r /dev/tty ]]; then
exec hermes chat --tui -s "$SKILL_NAME" --query "$KICKOFF" </dev/tty
fi
exec hermes chat --tui -s "$SKILL_NAME" --query "$KICKOFF"
fi fi
# Non-TTY fallback (CI/scripts): one-shot only # Reliable path: classic CLI + inject first user message
if [[ -n "$AUTO_PY" ]] && command -v python3 >/dev/null 2>&1; then
if [[ -r /dev/tty ]]; then
exec python3 "$AUTO_PY" "$@" </dev/tty >/dev/tty 2>/dev/tty
fi
exec python3 "$AUTO_PY" "$@"
fi
# Last resort: one-shot (not interactive after)
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" "$@"