Encode the robust way to reach + drive the phone in CLAUDE.md and the hh-operator skill (which had no mobile section at all): - One canonical hardened ssh prefix (ConnectTimeout/BatchMode/accept-new) that never hangs and dodges the wrong-alias trap. - Reachability gate probes the TCP port, NOT `tailscale status` — the tailnet shows fairphone-6 "active" even when Android has killed backgrounded Termux and sshd is dead (observed live). - Fix-is-on-device guidance (termux-wake-lock) instead of retrying a dead port; Termux env quirks (no /tmp, no rustc, skip srp). - Status advanced: phone can both JOIN and HOST rooms (server-side SRP shim) + the stdlib mobile web console mirrors a real room. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9.0 KiB
name, description
| name | description |
|---|---|
| hh-operator | Operate inside a hack-house chat room as a first-class participant — join, read/answer humans and AI, and (when granted) drive a sandbox with full shell + keystroke control. Use when asked to enter/run/watch a hackhouse room, bridge a Claude session into one, or autonomously monitor and respond in a room. |
hh-operator — drive a hack-house room from a Claude Code session
You become a room member. A small daemon owns the encrypted websocket; you
poll it through the hh-bridge CLI. The daemon survives across your tool
calls, so you can read → think → act → read again, indefinitely.
# Run from the hack-house repo. Pin the interpreter to its venv.
HH=".venv/bin/python -m cmd_chat.operator" # = "hh-bridge"
1. Join (once)
$HH up <host> <port> <name> --password <pw> [--no-tls] [--trigger "@bot"]
Spawns the daemon detached and waits until it's connected. <name> is your room
display name and the session id. --trigger adds an extra phrase that marks
a line "addressed" to you (your name / @name always count). Re-running up
when already connected is a no-op.
$HH status → connection, roster, grant state, sandbox target, last seq.
2. The operator loop
This is the core pattern. Long-poll for activity, decide, respond, repeat.
$HH read --wait --timeout 30 # blocks until events arrive (or timeout)
# → JSONL events; cursor auto-advances so the next bare `read` is "since last"
$HH say "your reply here" # send a chat line to the room
Each event: {seq, ts, kind, ...}. Kinds you act on:
message—{from, text, addressed}. Answer whenaddressed:true(or when context clearly invites you). Ignore idle chatter unless asked to.roster— who's present.acl— grant changes (granted,can_sudo).sandbox— a room sandbox came up/down.system— connect/reconnect.
In-turn autonomy: chain read --wait → act → read --wait within one turn
to stay responsive without burning idle tokens (the poll blocks server-side).
Indefinite watch (/loop): keep looping across turns — the daemon persists,
so you lose nothing between calls. Stop the loop when:
- the room/owner tells you to (
stop,halt,that's all,you can leave), - your given objective/stop-condition is met, or
- you're idle past your remit.
Then
$HH downto leave cleanly (drops the socket, leaves the roster).
3. Sandbox drive (when granted)
Sandbox actions are permission-gated: the room owner runs /grant <name>.
Until then exec/keys are refused/inert — that's by design, not a bug.
Two surfaces:
Co-located exec — capture output, scriptable, invisible to the room:
$HH sbx launch [--image IMG] [--engine podman|docker] # your own container
$HH sbx status # what you'd target
$HH exec 'whoami; uname -a' # run a shell command
echo "data" | $HH write /root/file.txt # stdin → file (binary-safe)
$HH get /root/file.txt [--out local.txt] # read a file out
$HH sbx down # tear your container down
sbx launch makes a throwaway container you own (podman→Kali, docker→Parrot).
When the room has a sandbox and you're granted, exec/write/get target
it directly (you're co-located on the host) — no launch needed.
Keystroke relay — drive the room's shared terminal live, like a human. The relay loop is type → wait → read:
$HH keys "make build" enter # type + run
$HH watch --for "BUILD (SUCCESS|FAIL)" --in screen --timeout 120 # wait
$HH screen # read the relayed terminal (ansi-stripped)
$HH keys ctrl-c # interrupt the running program
$HH keys --help-keys # print the full vocabulary
watch is the stop-condition engine — it blocks until a regex matches (in
screen output or chat events), or --idle N quiet, or --timeout — then
reports which fired. This is how you stay autonomous without busy-polling.
Keystroke cheat-sheet (how to stop things matters most)
Tokens combine: literal text types verbatim; named keys send control bytes;
text:<x> forces verbatim; hex:<bytes> sends raw.
| Key | Effect |
|---|---|
enter tab space esc |
\r \t ' ' \x1b |
ctrl-c |
SIGINT — interrupt the foreground program (your main "stop") |
ctrl-d |
EOF — end stdin / exit an empty shell |
ctrl-z |
suspend to background (resume with fg); ctrl-\ = SIGQUIT |
ctrl-u ctrl-l |
clear a half-typed line / clear the screen |
up down left right home end pageup pagedown delete backspace |
navigation |
q |
quits most pagers (less/man); :q + enter quits vim |
Rule: end a stuck command with ctrl-c; if ignored, ctrl-\ then ctrl-c
again. Always know how to exit an interactive program before you start it.
3b. Agent manifest — make a VM carry its own handoff record
A sandbox becomes shareable/tradeable when it documents itself. manifest
writes an ICM-style .hh-agent/ bundle inside the target sandbox so the next
agent (human or a spawned Claude) can resume without a briefing:
# stamp a fresh VM with purpose, goal, user intent, stop conditions
$HH manifest push --root /root --name kali-recon-vm \
--purpose "Kali recon sandbox" \
--objective "map the target and hand off" \
--intent "tradeable VM state across handoffs" --stop "scan complete"
# as work progresses, record state + a provenance line
$HH manifest update --root /root --status done --progress "scan done" \
--done "ran nmap" --todo "exploit phase" --note "oracle → child handoff"
# what a spawn/handoff reads first to resume exactly where you left off
$HH manifest pull --root /root # → parsed manifest.yaml
Bundle written under <root>/.hh-agent/: manifest.yaml (canonical machine
record), AGENT.md (read this first), last-state.md, summary.md,
goals.yaml. The yaml is the source of truth; the rest are rendered from it.
Pair with spawn: stamp → push → spawn a child whose objective is "load the
.hh-agent manifest and continue."
4. Delegate to a local model (optional)
A room often has an /ai <name> Ollama agent. To offload a task, just say
/ai <name> !<task> and read its reply — cheaper than doing trivial work
yourself. (Direct operator→Ollama delegation is a later phase.)
5. Mobile / Termux (Fairphone) — the phone is a live SSH host
The operator is pure Python and runs on the Fairphone-6 in Termux — it can
both join and (with the SRP shim's server side) host rooms, no Rust, no
srp C-ext. Drive the phone directly over SSH. Never tell the user "run this
on-device, paste it back" without probing reachability first — that framing is
wrong when the phone answers SSH.
One canonical, hardened invocation (kills the wrong-alias trap — see below):
PHONE='ssh -i ~/.ssh/phone-deploy -p 8022 -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new u0_a203@100.95.202.68'
$PHONE 'uname -m; python --version' # aarch64 / Python 3.13.x
BatchMode=yes fails fast instead of hanging on a password prompt;
ConnectTimeout=8 stops indefinite hangs; accept-new avoids the first-connect
host-key prompt while still pinning after.
Reachability gate — probe the PORT, not tailscale status:
timeout 8 bash -c 'cat </dev/null >/dev/tcp/100.95.202.68/8022' && echo REACHABLE || echo OFFLINE
tailscale status reports fairphone-6 active even when sshd is dead —
Android kills backgrounded Termux, taking sshd with it. So the tailnet line is
NOT proof; the TCP probe is. If the gate says OFFLINE the fix is on-device
(wake the phone, relaunch Termux, termux-wake-lock + start sshd), not a
retry loop from here — say so plainly rather than hammering a dead port.
The wrong-alias trap: ~/.ssh/config aliases phone / phone-deploy point
at 100.95.180.14 (trilluminati), a different, usually-offline device.
ssh phone will time out. Always use the Fairphone IP 100.95.202.68 (the
$PHONE prefix above), never the alias.
Termux env quirks that break naive commands:
- No
/tmp—$TMPDIRis$HOME/.claude-temp;$PREFIX=/data/data/com.termux/files/usr. rustc/opensslbinaries absent by default;git/clang/make/pkg-configpresent.- Deps:
pkg install python-cryptography(avoids a rust build);pip install requests rich websockets; skipsrp→ the shim (cmd_chat/client/ _srp_pure) covers both client and server. - Sync the tree with tar-over-ssh, not a stale clone:
tar czf - cmd_chat scripts | $PHONE 'tar xzf - -C ~/hh-mobile'.
Safety
- One bad action's blast radius is the container;
localexec is opt-in only. - Never run destructive host commands. Keystrokes/exec require an owner grant.
- Leave with
$HH downwhen done — don't abandon a live daemon.