mirror of
https://github.com/domfelipe/hermes-client-onboarding.git
synced 2026-08-07 07:36:48 +00:00
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:
parent
29c3fa703b
commit
d8bea2017b
5 changed files with 204 additions and 23 deletions
10
README.md
10
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
31
install.sh
31
install.sh
|
|
@ -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
|
||||||
|
local auto_py="${HOME}/.hermes/skills/${SKILL_NAME}/scripts/auto_kickoff_cli.py"
|
||||||
|
if [[ -f "$auto_py" ]] && command -v python3 >/dev/null 2>&1; then
|
||||||
if [[ -r /dev/tty ]]; then
|
if [[ -r /dev/tty ]]; then
|
||||||
exec hermes chat --tui -s "$SKILL_NAME" -q "$KICKOFF_MSG" </dev/tty
|
exec python3 "$auto_py" </dev/tty >/dev/tty 2>/dev/tty
|
||||||
else
|
|
||||||
exec hermes chat --tui -s "$SKILL_NAME" -q "$KICKOFF_MSG"
|
|
||||||
fi
|
fi
|
||||||
|
exec python3 "$auto_py"
|
||||||
|
fi
|
||||||
|
die "launcher missing — re-run install or: hermes chat --cli -s ${SKILL_NAME}"
|
||||||
}
|
}
|
||||||
|
|
||||||
launch_conductor() {
|
launch_conductor() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
127
skill/hermes-client-onboarding/scripts/auto_kickoff_cli.py
Executable file
127
skill/hermes-client-onboarding/scripts/auto_kickoff_cli.py
Executable 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())
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue