10 Commits

Author SHA1 Message Date
leetcrypt 8ba04316c1 fix(mobile-web): collapse carriage-return redraws in the console terminal
readline repaints its prompt in place with a lone \r (…└─# \r└─# cmd); the
browser turned that \r into a newline, stacking the pre-redraw prompt as a
phantom extra line (the mobile-only "extra --#"). Add a collapseCR() mini-vt
pass that resolves \r as a column-0 overwrite (ANSI escapes treated as
zero-width so colours survive; honours \x1b[K erase-to-EOL) before ansiToHtml,
so the real prompt stays and only the duplicate is dropped. TUI was already
correct via its real vt emulator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-16 11:07:17 -07:00
leetcrypt 012b7c15e5 docs(mobile): point quickstart setup at termux-bootstrap.sh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 22:40:06 -07:00
leetcrypt 3a90ee985a feat(mobile): termux-bootstrap.sh + robust ADB serial detection
termux-bootstrap.sh: one-shot on-device installer (pkg cryptography to avoid a
rust build, pure/wheeled pip deps, no srp C-ext, wake-lock, hh alias, Phase-0
preflight) — verified green on the Fairphone.

phone-capture.sh: auto-detect the adb device serial instead of assuming :5555,
since Wireless Debugging uses a dynamic port; target that serial for
screencap/screenrecord/pull. Clarified the pairing help to use the Tailscale IP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 22:39:47 -07:00
leetcrypt e97a45b78d docs(mobile): operator quickstart — hh launcher, direnv, web console, capture, skills
User-facing how-to distinct from the design spec: the port-probe reachability
gate, one-time Termux setup + tar-over-ssh sync, the hh launcher grammar,
per-directory direnv auto-host, the mobile web console, phone-capture.sh routes
(non-root file/term/webshot vs ADB shot/screen), and the Fairphone skill
ship-list. Linked from termux-operator.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 22:21:30 -07:00
leetcrypt f4572c985e feat(mobile): phone-capture.sh — pull screenshots/files/terminal from the Fairphone
Laptop-side capture tool that SSHes into the phone. Non-root routes work from
the Termux app uid (file pull via tar-over-ssh, tmux capture-pane, headless
chromium webshot of the hh web console); shot/screen use ADB (Wireless
Debugging) since screencap/screenrecord can't reach SurfaceFlinger as an app
uid. --tg [target] ships any artifact to Telegram via video-toolkit tg-send.sh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 22:17:53 -07:00
leetcrypt 2cd61ee598 feat(mobile): hh launcher + direnv auto-host for Termux/Fairphone
Add a single `hh` launcher (host/join/web/status/stop + operator
passthrough) and a direnv snippet so `cd`-ing into a project dir
auto-hosts a room named after it. Verified live on the Fairphone:
two rooms coexist on port-scoped tmux windows (manual :8799 +
direnv :8801), status health-checks each via curl (ss/netstat are
unreliable on Android), stop-all tears down cleanly.

Termux notes baked in: no /usr/bin/env (invoke via the `hh` alias,
not the shebang); status enumerates operator daemons under
$TMPDIR/hh-bridge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 22:10:53 -07:00
leetcrypt 4a17f1c6f1 docs(mobile): harden Fairphone/Termux SSH operations doctrine
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>
2026-07-07 21:54:54 -07:00
leetcrypt 7bd972f39c feat(srp): pure-Python server Verifier so Termux can HOST a room
The srp package's C-extension has no aarch64/bionic wheel, so a phone in
Termux couldn't run the room server. Add the server side of SRP-6a
(create_salted_verification_key + Verifier) to the pure-Python shim and
fall srp_auth.py back to it on ImportError.

Proven live: a laptop (C-ext srp client) authenticated against a room
HOSTED ON THE PHONE (pure Verifier) over Tailscale — cross-implementation
handshake with matching session keys. Locked in with interop tests
covering real-client-vs-pure-Verifier, pure-vs-pure, and a wrong-password
negative control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 21:39:17 -07:00
leetcrypt 1c36325d07 feat(operator): mobile hack-house console — terminal + keys + chat
Rebuild the web UI into a room mirror that looks/feels like the TUI:
tabbed Terminal + Chat, a live shared-sandbox terminal pane (polls
/api/screen, client-side ANSI/SGR render), keystroke drive with quick
keys + a command line (POST /api/keys), roster and a driver/watching
badge. Read-only until the room /grants this operator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 16:19:53 -07:00
leetcrypt 35da940cc8 feat(operator): mobile web chat UI in front of the daemon
Add `operator web` — a stdlib-only http.server shim over the control
socket so a phone browser can drive a room: message bubbles, live
long-poll updates, roster, and a real text box (GET /, /api/events,
/api/status, POST /api/say). Solves the Termux input gap where the
tmux `read --wait` loop is read-only. Binds 127.0.0.1 by default since
the socket grants room-send.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 16:00:10 -07:00
13 changed files with 1218 additions and 19 deletions
+26 -13
View File
@@ -31,32 +31,45 @@ $HH spawn "<objective incl. the full join cmd>" --room-host … --go --skip-perm
## Mobile operator on Android / Termux — the phone is REACHABLE, use it ## Mobile operator on Android / Termux — the phone is REACHABLE, use it
Spec: `docs/termux-operator.md`. The operator side is pure Python and runs on Spec: `docs/termux-operator.md`. The operator side is pure Python and runs on
the **Fairphone-6** in Termux. You can drive that device directly over SSH — so the **Fairphone-6** in Termux — it can both **join** and **host** rooms (the SRP
**verify reachability before ever telling the user "this can only run on-device, shim now covers the server side too, so no Rust / no `srp` C-ext). You can drive
paste it back."** That framing is wrong when the phone is a live SSH host. the device directly over SSH — so **verify reachability before ever telling the
user "this can only run on-device, paste it back."** That framing is wrong when
the phone answers SSH.
One canonical, hardened invocation (use this prefix everywhere — it sidesteps
the wrong-alias trap and never hangs):
```bash ```bash
# Fairphone-6 Termux over Tailscale (verified 2026-07-07). sshd on port 8022. PHONE='ssh -i ~/.ssh/phone-deploy -p 8022 -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new u0_a203@100.95.202.68'
ssh -i ~/.ssh/phone-deploy -p 8022 u0_a203@100.95.202.68 $PHONE 'uname -m; python --version' # aarch64 / Python 3.13.x
``` ```
- Tailscale device `fairphone-6` = **`100.95.202.68`**, port **8022**, user - **Reachability gate — probe the PORT, not `tailscale status`:**
**`u0_a203`**, key **`~/.ssh/phone-deploy`**. Confirm it's up first: `timeout 8 bash -c 'cat </dev/null >/dev/tcp/100.95.202.68/8022' && echo REACHABLE || echo OFFLINE`.
`tailscale status | grep fairphone` and a TCP probe of `:8022`. Tailscale reports `fairphone-6` **`active` even when sshd is dead** — Android
kills backgrounded Termux, taking `sshd` with it. The tailnet line is NOT
proof; the TCP probe is. If OFFLINE, the fix is **on-device** (wake the phone,
relaunch Termux, `termux-wake-lock` + start `sshd`) — say so, don't retry a
dead port.
- **Trap:** the `~/.ssh/config` aliases `phone` / `phone-deploy` point at - **Trap:** the `~/.ssh/config` aliases `phone` / `phone-deploy` point at
`100.95.180.14` (`trilluminati`), a *different, usually-offline* phone — `100.95.180.14` (`trilluminati`), a *different, usually-offline* phone —
`ssh phone` will time out. Use the Fairphone IP above, not the alias. `ssh phone` will time out. Use the Fairphone IP `100.95.202.68` (the `$PHONE`
prefix), not the alias.
- Termux facts: Python 3.13 (≥3.11); **no `/tmp`**`$TMPDIR` is - Termux facts: Python 3.13 (≥3.11); **no `/tmp`**`$TMPDIR` is
`$HOME/.claude-temp`; `$PREFIX=/data/data/com.termux/files/usr`; `rustc`/ `$HOME/.claude-temp`; `$PREFIX=/data/data/com.termux/files/usr`; `rustc`/
`openssl` not installed by default. `openssl` not installed by default.
- Deps that work: `pkg install python-cryptography` (avoid a rust build), - Deps that work: `pkg install python-cryptography` (avoid a rust build),
`pip install requests rich websockets`; **skip `srp`** — the client falls back `pip install requests rich websockets`; **skip `srp`**both client and
to `cmd_chat/client/_srp_pure` (pure-Python SRP-6a shim). server fall back to `cmd_chat/client/_srp_pure` (pure-Python SRP-6a shim).
- Push-restricted work: copy this tree to the phone with tar-over-ssh rather - Push-restricted work: copy this tree to the phone with tar-over-ssh rather
than a stale gitea clone — than a stale gitea clone —
`tar czf - cmd_chat scripts requirements-operator.txt | ssh … 'tar xzf - -C ~/hh-mobile'`. `tar czf - cmd_chat scripts requirements-operator.txt | $PHONE 'tar xzf - -C ~/hh-mobile'`.
- Phase-0 gate: `python scripts/termux-preflight.py` on the phone → expect - Phase-0 gate: `python scripts/termux-preflight.py` on the phone → expect
`RESULT: PASS`. Operator files currently staged at `~/hh-mobile` on-device. `RESULT: PASS`. Operator files staged at `~/hh-mobile` on-device.
- **Proven live (2026-07-07):** phone JOINS a dev-box room; the phone HOSTS a
room the laptop joins (cross-impl SRP: laptop C-ext ↔ phone pure Verifier);
and the stdlib mobile web console (`python -m cmd_chat.operator web`) mirrors a
real room (terminal + keystroke drive + chat) in a mobile browser.
## Key components ## Key components
+120 -5
View File
@@ -5,7 +5,7 @@ often fails to build under Termux/Android. This module reproduces the *exact*
client-side derivation of that package's pure-Python reference (``srp._pysrp``) client-side derivation of that package's pure-Python reference (``srp._pysrp``)
with ``rfc5054_enable()`` active, using only the standard library. with ``rfc5054_enable()`` active, using only the standard library.
Only the surface ``cmd_chat/client/client.py`` consumes is provided: The client surface ``cmd_chat/client/client.py`` consumes:
rfc5054_enable() # module-level toggle rfc5054_enable() # module-level toggle
SHA256 # hash-alg constant SHA256 # hash-alg constant
@@ -15,10 +15,19 @@ Only the surface ``cmd_chat/client/client.py`` consumes is provided:
usr.verify_session(H_AMK) # sets authenticated flag usr.verify_session(H_AMK) # sets authenticated flag
usr.authenticated() # -> bool usr.authenticated() # -> bool
The server uses the real ``srp`` library (see cmd_chat/server/srp_auth.py), so The server surface ``cmd_chat/server/srp_auth.py`` consumes (so a phone/Termux
every constant, padding rule, and hash input here mirrors ``srp._pysrp`` byte box with no ``srp`` C-ext can *host* a room, not only join one):
for byte — otherwise the M1 / H_AMK proofs would not match and auth would fail.
Correctness is gated by tests/test_srp_pure_interop.py. salt, vkey = create_salted_verification_key(b"chat", pw, hash_alg=SHA256)
svr = Verifier(b"chat", salt, vkey, A, hash_alg=SHA256)
s, B = svr.get_challenge() # -> (salt, B_bytes)
H_AMK = svr.verify_session(M) # -> H_AMK or None
K = svr.get_session_key()
Both sides mirror ``srp._pysrp`` byte for byte — every constant, padding rule,
and hash input — otherwise the M1 / H_AMK proofs would not match the real
library and auth would fail. Correctness is gated by
tests/test_srp_pure_interop.py (pure↔real in both directions).
""" """
import hashlib import hashlib
@@ -334,3 +343,109 @@ class User:
def verify_session(self, host_HAMK) -> None: def verify_session(self, host_HAMK) -> None:
if self.H_AMK == host_HAMK: if self.H_AMK == host_HAMK:
self._authenticated = True self._authenticated = True
# ── server side ──────────────────────────────────────────────────────────────
def create_salted_verification_key(
username,
password,
hash_alg=SHA1,
ng_type=NG_2048,
n_hex=None,
g_hex=None,
salt_len=4,
):
"""Return ``(salt, vkey)`` for a password — mirrors srp._pysrp exactly.
``v = g**x mod N`` where ``x = H(salt, H(username:password))``; the salt is
``salt_len`` random bytes. This is what the room stores in place of the
password (SRP never sees the password on the wire)."""
if ng_type == NG_CUSTOM and (n_hex is None or g_hex is None):
raise ValueError("Both n_hex and g_hex are required when ng_type = NG_CUSTOM")
hash_class = _hash_map[hash_alg]
N, g = _get_ng(ng_type, n_hex, g_hex)
_s = _long_to_bytes(_get_random(salt_len))
_v = _long_to_bytes(pow(g, _gen_x(hash_class, _s, username, password), N))
return _s, _v
class Verifier:
"""Server side of the SRP-6a exchange, matching srp._pysrp.Verifier's surface."""
def __init__(
self,
username,
bytes_s,
bytes_v,
bytes_A,
hash_alg=SHA1,
ng_type=NG_2048,
n_hex=None,
g_hex=None,
bytes_b=None,
):
if ng_type == NG_CUSTOM and (n_hex is None or g_hex is None):
raise ValueError("Both n_hex and g_hex are required when ng_type = NG_CUSTOM")
if bytes_b and len(bytes_b) != 32:
raise ValueError("32 bytes required for bytes_b")
self.A = _bytes_to_long(bytes_A)
self.M = None
self.K = None
self.H_AMK = None
self._authenticated = False
self.I = username
self.s = bytes_s
self.v = _bytes_to_long(bytes_v)
N, g = _get_ng(ng_type, n_hex, g_hex)
hash_class = _hash_map[hash_alg]
k = _bytes_to_long(_H(hash_class, N, g, width=len(_long_to_bytes(N))))
self.hash_class = hash_class
self.N = N
self.g = g
self.k = k
# SRP-6a safety check: abort (no challenge) if A is a multiple of N.
self.safety_failed = False
if (self.A % N) == 0:
self.safety_failed = True
self.B = None
return
if bytes_b:
self.b = _bytes_to_long(bytes_b)
else:
self.b = _get_random_of_length(32)
self.B = (k * self.v + pow(g, self.b, N)) % N
self.u = _bytes_to_long(_H(hash_class, self.A, self.B, width=len(_long_to_bytes(N))))
self.S = pow(self.A * pow(self.v, self.u, N), self.b, N)
self.K = hash_class(_long_to_bytes(self.S)).digest()
self.M = _calculate_M(hash_class, N, g, self.I, self.s, self.A, self.B, self.K)
self.H_AMK = _calculate_H_AMK(hash_class, self.A, self.M, self.K)
def authenticated(self) -> bool:
return self._authenticated
def get_username(self):
return self.I
def get_ephemeral_secret(self) -> bytes:
return _long_to_bytes(self.b)
def get_session_key(self):
return self.K if self._authenticated else None
def get_challenge(self):
"""Return ``(salt, B_bytes)`` or ``(None, None)`` if the safety check failed."""
if self.safety_failed:
return None, None
return (self.s, _long_to_bytes(self.B))
def verify_session(self, user_M):
"""Check the client proof M; return H_AMK (bytes) on success, else None."""
if not self.safety_failed and user_M == self.M:
self._authenticated = True
return self.H_AMK
return None
+14
View File
@@ -136,6 +136,13 @@ def _build_parser() -> argparse.ArgumentParser:
op.add_argument("--cost", type=float, default=5.0) op.add_argument("--cost", type=float, default=5.0)
op.add_argument("--session", default=None) op.add_argument("--session", default=None)
wb = sub.add_parser("web", help="serve a mobile chat UI in front of the daemon")
wb.add_argument("--bind", default="127.0.0.1",
help="listen address (default: 127.0.0.1 — loopback only; "
"the control socket grants room-send, so keep it local)")
wb.add_argument("--port", type=int, default=8790, help="listen port (default: 8790)")
wb.add_argument("--session", default=None)
sc = sub.add_parser("screen", help="print the relayed sandbox terminal buffer") sc = sub.add_parser("screen", help="print the relayed sandbox terminal buffer")
sc.add_argument("--tail", type=int, default=None, help="last N bytes only") sc.add_argument("--tail", type=int, default=None, help="last N bytes only")
sc.add_argument("--ansi", action="store_true", help="keep ANSI codes (raw)") sc.add_argument("--ansi", action="store_true", help="keep ANSI codes (raw)")
@@ -560,6 +567,11 @@ def _run_registry(args) -> int:
return 0 return 0
def _run_web(args) -> int:
from .web import serve
return serve(args.session, args.bind, args.port)
def _run_simple(args, op: str) -> int: def _run_simple(args, op: str) -> int:
resp = _client_request(args, {"op": op}) resp = _client_request(args, {"op": op})
print(json.dumps(resp)) print(json.dumps(resp))
@@ -599,6 +611,8 @@ def main(argv: list[str] | None = None) -> int:
return _run_screen(args) return _run_screen(args)
if verb == "watch": if verb == "watch":
return _run_watch(args) return _run_watch(args)
if verb == "web":
return _run_web(args)
if verb in ("roster", "status", "down"): if verb in ("roster", "status", "down"):
return _run_simple(args, verb) return _run_simple(args, verb)
return 2 return 2
+446
View File
@@ -0,0 +1,446 @@
"""Mobile hack-house console for the operator bridge (`operator web`).
A phone-friendly browser mirror of a live hack-house room. The daemon already
exposes the room as a local API over its AF_UNIX control socket; this module is
a thin, **stdlib-only** HTTP shim in front of it so a browser gets the same
things the Rust TUI shows — the shared sandbox terminal, chat, roster — mobile
optimised:
GET / the single-page console (self-contained HTML/JS)
GET /api/status proxies {"op":"status"} (connection/roster/driver)
GET /api/events?since=N long-poll: proxies {"op":"read","wait":true}
POST /api/say {text} proxies {"op":"say"}
GET /api/screen?ansi=1 proxies {"op":"screen"} — the relayed shared PTY
POST /api/keys {tokens} proxies {"op":"keys"} — drive the shared PTY
Why this exists: on Termux the tmux `read --wait` loop is read-only and shows
none of the sandbox. A browser page gives the terminal view + a real input box
+ live updates with zero extra dependencies (no server stack, no JS build).
Binds 127.0.0.1 by default: the control socket grants room-send/drive rights,
so the listener stays loopback-only unless you pass a routable --bind.
"""
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from .cli_client import BridgeUnreachable, request
from .session import resolve
# Long-poll window the browser holds open per /api/events call. The socket read
# timeout must outlast it (see request(read_timeout=...)).
_POLL_TIMEOUT = 25.0
_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>hack-house · __SESSION__</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; margin: 0; }
body {
font: 14px/1.4 -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
background: #0b0e14; color: #cdd6f4; display: flex; flex-direction: column;
overflow: hidden;
}
header {
padding: 8px 12px; background: #11151f; border-bottom: 1px solid #1f2430;
display: flex; align-items: center; gap: 8px; flex: none;
}
header .dot { width: 9px; height: 9px; border-radius: 50%; background: #f38ba8; flex: none; }
header .dot.on { background: #a6e3a1; box-shadow: 0 0 6px #a6e3a1; }
header .name { font-weight: 700; letter-spacing: .3px; }
header .badge { font-size: 11px; padding: 1px 7px; border-radius: 10px;
background: #313244; color: #9399b2; }
header .badge.drive { background: #1e3a2a; color: #a6e3a1; }
header .roster { color: #7f849c; font-size: 12px; margin-left: auto;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 45%; }
.tabs { display: flex; flex: none; background: #11151f; border-bottom: 1px solid #1f2430; }
.tabs button { flex: 1; background: none; border: 0; color: #7f849c; padding: 9px;
font: inherit; font-weight: 600; border-bottom: 2px solid transparent; }
.tabs button.active { color: #89b4fa; border-bottom-color: #89b4fa; }
.pane { flex: 1; min-height: 0; display: none; flex-direction: column; }
.pane.active { display: flex; }
/* terminal */
#term { flex: 1; margin: 0; padding: 10px; overflow: auto; white-space: pre;
font: 12px/1.35 "SF Mono", ui-monospace, Menlo, Consolas, monospace;
background: #05070c; color: #b9c0d4; }
#term .empty { color: #565a70; font-style: italic; }
.keys { flex: none; background: #11151f; border-top: 1px solid #1f2430; padding: 6px; }
.quick { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; }
.quick button { flex: none; background: #1f2430; color: #cdd6f4; border: 1px solid #313244;
border-radius: 7px; padding: 6px 9px; font: 12px monospace; }
.quick button:active { background: #313244; }
.keyrow { display: flex; gap: 6px; }
#kbox { flex: 1; background: #05070c; color: #b9c0d4; border: 1px solid #313244;
border-radius: 8px; padding: 8px; font: 13px monospace; }
#kbox:disabled { opacity: .5; }
#ksend { flex: none; background: #89b4fa; color: #0b0e14; border: 0; border-radius: 8px;
padding: 0 14px; font: inherit; font-weight: 700; }
#ksend:disabled { opacity: .4; }
.hint { color: #f9e2af; font-size: 11px; padding: 4px 2px 0; }
/* chat */
#log { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 7px; }
.msg { max-width: 82%; padding: 7px 10px; border-radius: 13px; word-wrap: break-word; white-space: pre-wrap; }
.msg .who { font-size: 10px; color: #7f849c; margin-bottom: 2px; }
.msg.them { background: #1f2430; align-self: flex-start; border-bottom-left-radius: 4px; }
.msg.me { background: #3a4a7a; align-self: flex-end; border-bottom-right-radius: 4px; }
.msg.addr { box-shadow: 0 0 0 2px #f9e2af inset; }
.sys { align-self: center; color: #7f849c; font-size: 11px; font-style: italic; }
footer { flex: none; display: flex; gap: 7px; padding: 8px; background: #11151f; border-top: 1px solid #1f2430; }
#box { flex: 1; resize: none; background: #05070c; color: #cdd6f4; border: 1px solid #313244;
border-radius: 9px; padding: 9px; font: inherit; max-height: 110px; }
#send { flex: none; background: #a6e3a1; color: #0b0e14; border: 0; border-radius: 9px;
padding: 0 16px; font: inherit; font-weight: 700; }
#send:disabled { opacity: .5; }
/* ANSI palette */
.a30{color:#45475a}.a31{color:#f38ba8}.a32{color:#a6e3a1}.a33{color:#f9e2af}
.a34{color:#89b4fa}.a35{color:#f5c2e7}.a36{color:#94e2d5}.a37{color:#bac2de}
.a90{color:#585b70}.a91{color:#f38ba8}.a92{color:#a6e3a1}.a93{color:#f9e2af}
.a94{color:#89b4fa}.a95{color:#f5c2e7}.a96{color:#94e2d5}.a97{color:#e6edf3}
.ab{font-weight:700}
</style>
</head>
<body>
<header>
<span class="dot" id="dot"></span>
<span class="name">__SESSION__</span>
<span class="badge" id="drive">watching</span>
<span class="roster" id="roster">—</span>
</header>
<div class="tabs">
<button id="tabTerm" class="active">Terminal</button>
<button id="tabChat">Chat</button>
</div>
<div class="pane active" id="paneTerm">
<pre id="term"><span class="empty">no shared sandbox yet — a room member runs /sbx and /grants this operator to drive it here.</span></pre>
<div class="keys">
<div class="quick">
<button data-k="enter">⏎ Enter</button>
<button data-k="ctrl-c">^C</button>
<button data-k="esc">Esc</button>
<button data-k="tab">Tab</button>
<button data-k="up">↑</button>
<button data-k="down">↓</button>
<button data-k="ctrl-l">clear</button>
<button data-k="ctrl-d">^D</button>
</div>
<div class="keyrow">
<input id="kbox" placeholder="type a command → Send runs it (adds Enter)" autocomplete="off" autocapitalize="off" spellcheck="false">
<button id="ksend">Send</button>
</div>
<div class="hint" id="khint"></div>
</div>
</div>
<div class="pane" id="paneChat">
<div id="log"></div>
<footer>
<textarea id="box" rows="1" placeholder="Message the room…" autocomplete="off"></textarea>
<button id="send">Send</button>
</footer>
</div>
<script>
const ME = "__SESSION__";
const $ = id => document.getElementById(id);
let since = 0, granted = false, active = "term", termSeq = -1;
const seen = new Set();
/* ── tabs ── */
function show(tab) {
active = tab;
$("tabTerm").classList.toggle("active", tab === "term");
$("tabChat").classList.toggle("active", tab === "chat");
$("paneTerm").classList.toggle("active", tab === "term");
$("paneChat").classList.toggle("active", tab === "chat");
}
$("tabTerm").onclick = () => show("term");
$("tabChat").onclick = () => show("chat");
/* ── ANSI (SGR) → HTML for the terminal pane ── */
function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
function ansiToHtml(raw) {
let out = "", open = false, cls = [];
const re = /\\x1b\\[([0-9;]*)m|\\x1b\\[[0-9;?]*[A-Za-z]|\\x1b\\][^\\x07]*\\x07/g;
let last = 0, m;
const flush = t => { if (!t) return; out += esc(t); };
while ((m = re.exec(raw)) !== null) {
flush(raw.slice(last, m.index)); last = re.lastIndex;
if (m[1] === undefined) continue; // non-SGR CSI / OSC → drop
const codes = m[1] === "" ? [0] : m[1].split(";").map(Number);
if (open) { out += "</span>"; open = false; }
cls = [];
for (const c of codes) {
if (c === 0) cls = [];
else if (c === 1) cls.push("ab");
else if ((c>=30&&c<=37)||(c>=90&&c<=97)) cls.push("a"+c);
}
if (cls.length) { out += '<span class="'+cls.join(" ")+'">'; open = true; }
}
flush(raw.slice(last));
if (open) out += "</span>";
return out;
}
/* ── carriage-return overwrite (mini vt) ──
A real terminal treats a lone \\r as "cursor to column 0" so readline's
in-place prompt redraw (…└─# \\r└─# cmd) overwrites the old line. The DOM
instead turns \\r into \\n, stacking the pre-redraw prompt as a phantom
extra line. Resolve overwrites per \\n-delimited line, treating ANSI escapes
as zero-width (attached to the next column) so colours survive; honour
erase-to-EOL (\\x1b[K) so a shrinking redraw leaves no tail chars. */
function collapseCR(raw) {
const escRe = /^(?:\\x1b\\[[0-9;?]*[ -\\/]*[@-~]|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)|\\x1b[@-Z\\\\-_])/;
const elRe = /^\\x1b\\[[02]?K$/;
const lines = [];
let cells = [], col = 0, pending = "";
function flush() {
let s = "";
for (let k = 0; k < cells.length; k++) s += cells[k] ? (cells[k].pre + cells[k].ch) : "";
lines.push(s + pending); pending = ""; cells = []; col = 0;
}
for (let i = 0; i < raw.length; ) {
const m = escRe.exec(raw.slice(i));
if (m) {
const seq = m[0];
if (elRe.test(seq)) cells.length = col; // erase to end of line
else pending += seq; // zero-width; ride the next char
i += seq.length; continue;
}
const ch = raw[i++];
if (ch === "\\n") flush();
else if (ch === "\\r") col = 0; // overwrite from column 0
else { cells[col] = {pre: pending, ch: ch}; pending = ""; col++; }
}
flush();
return lines.join("\\n");
}
/* ── terminal poll ── */
async function pollScreen() {
for (;;) {
try {
if (active === "term") {
const r = await fetch("/api/screen?ansi=1");
const d = await r.json();
if (d.ok && d.seq !== termSeq) {
termSeq = d.seq;
const t = $("term");
const bottom = t.scrollHeight - t.scrollTop - t.clientHeight < 40;
if (d.text && d.text.length) t.innerHTML = ansiToHtml(collapseCR(d.text));
if (bottom) t.scrollTop = t.scrollHeight;
}
}
} catch (e) {}
await new Promise(r => setTimeout(r, active === "term" ? 900 : 2000));
}
}
/* ── keys ── */
async function sendKeys(tokens) {
if (!granted) return;
try {
const r = await fetch("/api/keys", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({tokens})});
const j = await r.json();
if (!j.ok) $("khint").textContent = "keys: " + (j.error || "?");
} catch (e) { $("khint").textContent = "keys: offline"; }
}
document.querySelectorAll(".quick button").forEach(b =>
b.onclick = () => sendKeys([b.dataset.k]));
function runLine() {
const v = $("kbox").value;
if (!granted) return;
sendKeys(v ? ["text:" + v, "enter"] : ["enter"]);
$("kbox").value = "";
}
$("ksend").onclick = runLine;
$("kbox").addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); runLine(); } });
function setGrant(g) {
granted = g;
$("drive").textContent = g ? "driver" : "watching";
$("drive").classList.toggle("drive", g);
$("kbox").disabled = !g; $("ksend").disabled = !g;
document.querySelectorAll(".quick button").forEach(b => b.disabled = !g);
$("khint").textContent = g ? "" : "read-only — a room owner must /grant " + ME + " to drive the sandbox";
}
/* ── chat ── */
const log = $("log"), box = $("box"), send = $("send"), dot = $("dot"), rosterEl = $("roster");
function atBottom(){ return log.scrollHeight - log.scrollTop - log.clientHeight < 60; }
function addMsg(from, text, mine, addressed) {
const el = document.createElement("div");
el.className = "msg " + (mine ? "me" : "them") + (addressed ? " addr" : "");
if (!mine){ const w=document.createElement("div"); w.className="who"; w.textContent=from; el.appendChild(w); }
el.appendChild(document.createTextNode(text));
const stick = atBottom(); log.appendChild(el); if (stick||mine) log.scrollTop = log.scrollHeight;
}
function addSys(text){ const el=document.createElement("div"); el.className="sys"; el.textContent=text;
const stick=atBottom(); log.appendChild(el); if(stick) log.scrollTop=log.scrollHeight; }
function setRoster(u){ rosterEl.textContent = (u||[]).length + " · " + (u||[]).join(", "); }
function render(ev) {
if (seen.has(ev.seq)) return; seen.add(ev.seq);
if (ev.kind === "message") addMsg(ev.from, ev.text, false, ev.addressed);
else if (ev.kind === "sent") addMsg(ev.from, ev.text, true, false);
else if (ev.kind === "roster") setRoster(ev.users);
else if (ev.kind === "system") {
if (ev.event === "connected") dot.classList.add("on");
if (ev.event === "reconnecting") dot.classList.remove("on");
addSys("· " + (ev.event||"") + (ev.reason ? " ("+ev.reason+")" : "") + " ·");
} else if (ev.kind === "acl") { setGrant(!!ev.granted); addSys("· drive "+(ev.granted?"granted":"revoked")+" ·"); }
else if (ev.kind === "sandbox") addSys("· sandbox " + (ev.state||"") + " ·");
}
async function refreshStatus() {
try { const s = await (await fetch("/api/status")).json();
dot.classList.toggle("on", !!s.connected);
if (s.users) setRoster(s.users);
setGrant(!!s.granted);
} catch (e) {}
}
async function pollEvents() {
for (;;) {
try {
const d = await (await fetch("/api/events?since=" + since)).json();
for (const ev of (d.events || [])) { since = Math.max(since, ev.seq); render(ev); }
} catch (e) { dot.classList.remove("on"); await new Promise(r=>setTimeout(r,2000)); }
}
}
async function doSend() {
const text = box.value.trim(); if (!text) return;
send.disabled = true;
try {
const j = await (await fetch("/api/say",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({text})})).json();
if (j.ok){ box.value=""; box.style.height="auto"; } else addSys("· send failed: "+(j.error||"?")+" ·");
} catch(e){ addSys("· send failed (offline) ·"); }
send.disabled = false; box.focus();
}
send.onclick = doSend;
box.addEventListener("keydown", e => { if (e.key==="Enter" && !e.shiftKey){ e.preventDefault(); doSend(); } });
box.addEventListener("input", () => { box.style.height="auto"; box.style.height=Math.min(box.scrollHeight,110)+"px"; });
refreshStatus(); pollEvents(); pollScreen();
</script>
</body>
</html>
"""
class _Handler(BaseHTTPRequestHandler):
session_name = "default" # set on the class before serving
protocol_version = "HTTP/1.1"
def log_message(self, *a): # silence per-request stderr spam
pass
def _sock(self):
return resolve(self.session_name).sock_path
def _send_json(self, obj: dict, code: int = 200) -> None:
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _bridge(self, req: dict, read_timeout: float = 35.0) -> dict:
try:
return request(self._sock(), req, read_timeout=read_timeout)
except BridgeUnreachable as e:
return {"ok": False, "error": str(e), "offline": True}
# ── routes ───────────────────────────────────────────────────────────
def do_GET(self) -> None:
url = urlparse(self.path)
if url.path in ("/", "/index.html"):
page = _PAGE.replace("__SESSION__", resolve(self.session_name).name)
body = page.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if url.path == "/api/status":
self._send_json(self._bridge({"op": "status"}, read_timeout=8))
return
if url.path == "/api/screen":
q = parse_qs(url.query)
ansi = (q.get("ansi") or ["0"])[0] not in ("0", "", "false")
tail = q.get("tail")
req = {"op": "screen", "ansi": ansi}
if tail:
req["tail"] = int(tail[0])
self._send_json(self._bridge(req, read_timeout=8))
return
if url.path == "/api/events":
q = parse_qs(url.query)
since = int((q.get("since") or ["0"])[0])
self._send_json(self._bridge(
{"op": "read", "since": since, "wait": True, "timeout": _POLL_TIMEOUT},
read_timeout=_POLL_TIMEOUT + 5.0))
return
self._send_json({"ok": False, "error": "not found"}, code=404)
def _read_body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
obj = json.loads(raw.decode())
return obj if isinstance(obj, dict) else {}
except ValueError:
return {}
def do_POST(self) -> None:
url = urlparse(self.path)
if url.path == "/api/say":
text = str(self._read_body().get("text", "")).strip()
if not text:
self._send_json({"ok": False, "error": "empty"}, code=400)
return
self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15))
return
if url.path == "/api/keys":
tokens = self._read_body().get("tokens")
if not isinstance(tokens, list) or not tokens:
self._send_json({"ok": False, "error": "no tokens"}, code=400)
return
self._send_json(self._bridge({"op": "keys", "keys": tokens}, read_timeout=10))
return
self._send_json({"ok": False, "error": "not found"}, code=404)
def serve(session_name: str, bind: str, port: int) -> int:
"""Run the web front-end until Ctrl-C. Returns a process exit code."""
sess = resolve(session_name)
if not sess.sock_path.exists():
print(f"no live bridge for session '{sess.name}' (run `up` first)")
return 2
_Handler.session_name = session_name
httpd = ThreadingHTTPServer((bind, port), _Handler)
httpd.daemon_threads = True
shown = bind if bind not in ("0.0.0.0", "::") else "127.0.0.1"
print(f"operator web console for session '{sess.name}' → http://{shown}:{port} (Ctrl-C to stop)")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nweb console stopped")
finally:
httpd.server_close()
return 0
+3
View File
@@ -3,7 +3,10 @@ from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
try:
import srp import srp
except ImportError: # no aarch64 wheel / C-ext build (Termux) — use the shim
from ..client import _srp_pure as srp
srp.rfc5054_enable() srp.rfc5054_enable()
+157
View File
@@ -0,0 +1,157 @@
# hack-house on the phone — mobile quickstart
Run and drive a hack-house room from the **Fairphone-6** (Termux, Android). This
is the *operator's* how-to; the design rationale lives in
[`termux-operator.md`](termux-operator.md).
Two devices are in play:
- **the phone** — Termux, `aarch64`, Python 3.13, reached over Tailscale as
`100.95.202.68` (sshd on **:8022**, user `u0_a203`). Hosts/joins rooms.
- **the laptop / dev-box** — pulls captures off the phone, joins phone-hosted
rooms, and does any chromium/Whisper-heavy work the phone can't.
> **Reachability first.** Android kills backgrounded Termux, taking `sshd` with
> it — and `tailscale status` still shows the phone `active`. Always probe the
> **port**, not the tailnet:
> ```bash
> timeout 8 bash -c 'cat </dev/null >/dev/tcp/100.95.202.68/8022' \
> && echo REACHABLE || echo OFFLINE
> ```
> If OFFLINE the fix is on-device: wake the phone, open Termux, run
> `termux-wake-lock` and start `sshd`. Don't hammer a dead port.
---
## 1. One-time on-device setup
**Step 1 — sync the tree** onto the phone (tar-over-ssh from the laptop; no gitea
clone needed if the branch is unpushed):
```bash
# from the laptop, in this repo
tar czf - cmd_chat scripts requirements-operator.txt \
| ssh -i ~/.ssh/phone-deploy -p 8022 u0_a203@100.95.202.68 'mkdir -p ~/hh-mobile && tar xzf - -C ~/hh-mobile'
```
**Step 2 — run the bootstrap** in Termux on the phone. It installs deps
(`python-cryptography` to dodge a rust build; pure/wheeled pip deps; **no** `srp`
C-ext — the shim covers it), holds a wake-lock, installs the `hh` alias, and runs
the Phase-0 preflight. Idempotent — safe to re-run.
```bash
# in Termux, on the phone
bash ~/hh-mobile/scripts/termux-bootstrap.sh
source ~/.bashrc # activate the 'hh' alias
```
Expect the preflight to end `RESULT: PASS`. (Manual equivalent, if you'd rather:
`pkg install python tmux git python-cryptography openssh` · `pip install requests
rich websockets` · `termux-wake-lock` · add `alias hh='bash
"$HOME/hh-mobile/scripts/hh"'` to `~/.bashrc`.)
---
## 2. The `hh` launcher
Everything runs in a persistent tmux session named `hh`, so it survives an SSH
drop. `HH_PY` defaults to `python`; override any config via env (see the header
of `scripts/hh`).
```bash
hh host [NAME] [PORT] # host a room ON THE PHONE (default :8799)
hh join HOST PORT [NAME] # join an existing room as operator
hh web [PORT] # serve the mobile web console (default :8790)
hh op <args...> # passthrough to `python -m cmd_chat.operator`
hh status # what's running + listening (curl health per room)
hh stop [all|host [PORT]|web [PORT]|join]
```
Room windows are **port-scoped** (`host-8799`, `web-8790`), so several rooms
coexist — one per directory when paired with direnv below.
```bash
hh host myroom 8799 # → hosting on 0.0.0.0:8799 (password: hh-mobile)
hh status # host :8799 UP (health json) · operator daemons
hh stop host 8799 # tear down just that room
```
Config env (all optional): `HH_PASSWORD` (hh-mobile), `HH_PORT` (8799),
`HH_WEB_PORT` (8790), `HH_NAME` (dir basename), `HH_BIND` (0.0.0.0), `HH_TLS`
(1 → keep TLS; default drops it with `--no-tls`).
## 3. Auto-host a room per directory (direnv)
Drop an `.envrc` in any project dir so `cd`-ing in auto-hosts a room named after
the directory:
```bash
# .envrc
export HH_PORT=8801 # optional; override before sourcing
source "$HOME/hh-mobile/scripts/hh-direnv.sh"
```
`direnv allow`, then every `cd` in idempotently ensures the room is up (the
launcher no-ops if the window already exists). Disable hosting but keep the env
with `export HH_AUTOHOST=0`.
## 4. Mobile web console
`hh web` serves a stdlib web UI in front of the operator daemon — Terminal + Chat
tabs, a quick-keys row (Enter/^C/Esc/Tab/↑/↓/clear/^D), and a command box.
- On the phone browser: `http://127.0.0.1:8790`
- From the laptop: `http://100.95.202.68:8790`
It's **read-only** until a room owner runs `/grant <name>`; until then keys/exec
are inert by design.
---
## 5. Capture & share from the phone (`scripts/phone-capture.sh`)
Runs on the **laptop**; it SSHes into the phone and pulls content back. Add
`--tg [target]` to any command to also ship the artifact to Telegram.
```bash
scripts/phone-capture.sh file <remote> [local] # pull a file/dir (tar over ssh)
scripts/phone-capture.sh term [tmux-session] # capture tmux pane text (default: hh)
scripts/phone-capture.sh webshot <url> [png] # screenshot a URL via headless chromium
scripts/phone-capture.sh shot [png] # device screenshot (needs ADB)
scripts/phone-capture.sh screen [secs] [mp4] # device screen record (needs ADB)
```
**Non-root routes** (`file`, `term`, `webshot`) work straight from the Termux app
uid. Example — grab the live web console as a PNG:
```bash
scripts/phone-capture.sh webshot 'http://100.95.202.68:8790/' --tg
```
**Screen capture** (`shot`/`screen`) can't reach SurfaceFlinger from an app uid,
so it needs ADB over **Wireless Debugging** (Settings → System → Developer
options). If ADB isn't connected the script prints the one-time pairing steps.
Env overrides: `PHONE_IP`, `PHONE_PORT`, `PHONE_USER`, `PHONE_KEY`; captures land
in `~/coding/_data/phone-captures/`.
---
## 6. Which Claude skills to ship to the phone
Lean, self-contained skills that earn their place on a phone (bash/curl/ssh, no
chromium, no build step):
**Ship now:** `hh-operator` · `tg-send`/`tg-direct` (ship captures out) ·
`transcribe` (offloads to laptop Whisper over SSH — ideal for a phone) ·
`dns-toolkit` · `news-intel` · `session-audit` · `project-map` · `site-audit`.
**Tether, don't relocate:** `screenshot` (already covered by
`phone-capture.sh webshot`), `web-video-grab` (yt-dlp mode only — the Playwright
path needs chromium), `youtube-research`/`vault-*`/`imagegen` (laptop-heavy).
**Skip:** anything GPU/vision, local-infra (gitea-admin, Nuclei/Suricata
digests), creds-on-laptop (IMAP/gopass email), or OAO business skills.
Leanest starter kit: `hh-operator + tg-send + transcribe + dns-toolkit +
session-audit + news-intel`.
+4
View File
@@ -10,6 +10,10 @@ reverse-SSH documented as alternates.
Target device is the Murena Fairphone 6 (Android 15/16 base) running Termux. A Target device is the Murena Fairphone 6 (Android 15/16 base) running Termux. A
future postmarketOS path (full Linux userland) is noted in Appendix C. future postmarketOS path (full Linux userland) is noted in Appendix C.
> **Operators:** for the day-to-day how-to (the `hh` launcher, direnv auto-host,
> the mobile web console, and pulling captures back to the laptop) see
> [`mobile-quickstart.md`](mobile-quickstart.md). This document is the design spec.
--- ---
## 1. Why this is tractable ## 1. Why this is tractable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# hh — hack-house mobile launcher (Termux/Fairphone friendly).
#
# hh host [NAME] [PORT] start a room ON THIS DEVICE (tmux window)
# hh join HOST PORT [NAME] join an existing room as an operator (daemon)
# hh web [PORT] serve the mobile web console (tmux window)
# hh op <operator args...> passthrough to `python -m cmd_chat.operator`
# hh status what's running + listening
# hh stop [all|host|web|join] tear it down
#
# Config via env (all optional):
# HH_PASSWORD room password (default: hh-mobile)
# HH_PORT host port (default: 8799)
# HH_WEB_PORT web console port (default: 8790)
# HH_NAME room / operator name (default: current dir basename)
# HH_BIND host/web bind addr (default: 0.0.0.0 — reachable over Tailscale)
# HH_TLS set to 1 to drop --no-tls (default: no-tls)
set -euo pipefail
# --- locate the repo root (this script lives in <root>/scripts/hh) -----------
_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(dirname "$_self")"
cd "$ROOT"
PY="${HH_PY:-python}"
PW="${HH_PASSWORD:-hh-mobile}"
PORT="${HH_PORT:-8799}"
WEB_PORT="${HH_WEB_PORT:-8790}"
BIND="${HH_BIND:-0.0.0.0}"
NAME="${HH_NAME:-$(basename "$ROOT")}"
TLS_FLAG="--no-tls"; [ "${HH_TLS:-0}" = "1" ] && TLS_FLAG=""
TMUX_SES="hh"
OP="$PY -m cmd_chat.operator"
_die() { echo "hh: $*" >&2; exit 1; }
command -v "$PY" >/dev/null || _die "python ('$PY') not found"
# run a long-lived process in a named tmux window (survives SSH disconnect)
_tmux_run() { # <window> <command string>
local win="$1"; shift
command -v tmux >/dev/null || _die "tmux not found (pkg install tmux)"
tmux has-session -t "$TMUX_SES" 2>/dev/null || tmux new-session -d -s "$TMUX_SES" -n main
if tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null | grep -qx "$win"; then
echo "hh: window '$win' already running (hh stop $win to replace)"; return 0
fi
tmux new-window -t "$TMUX_SES" -n "$win" "cd '$ROOT'; $*; echo '[$win exited — press enter]'; read"
}
# best-effort Tailscale IP for the "reachable at" hint
_ts_ip() { command -v tailscale >/dev/null && tailscale ip -4 2>/dev/null | head -1 || true; }
cmd="${1:-status}"; shift || true
case "$cmd" in
host)
[ $# -ge 1 ] && NAME="$1"; [ $# -ge 2 ] && PORT="$2"
# window name is port-scoped so several rooms (one per direnv dir) coexist
_tmux_run "host-$PORT" "$PY cmd_chat.py serve '$BIND' '$PORT' --password '$PW' $TLS_FLAG"
ip="$(_ts_ip)"
echo "hh: hosting room '$NAME' on $BIND:$PORT (password: $PW)"
[ -n "$ip" ] && echo " reachable at: $ip:$PORT"
;;
join)
[ $# -ge 2 ] || _die "usage: hh join HOST PORT [NAME]"
local_host="$1"; local_port="$2"; jname="${3:-$NAME}"
$OP up "$local_host" "$local_port" "$jname" --password "$PW" $TLS_FLAG
echo "hh: joined $local_host:$local_port as '$jname' — use: hh op read --wait / hh op say '...'"
;;
web)
[ $# -ge 1 ] && WEB_PORT="$1"
_tmux_run "web-$WEB_PORT" "$OP web --bind '$BIND' --port '$WEB_PORT'"
ip="$(_ts_ip)"
echo "hh: web console on $BIND:$WEB_PORT"
[ -n "$ip" ] && echo " open on phone browser: http://127.0.0.1:$WEB_PORT | from laptop: http://$ip:$WEB_PORT"
;;
op) exec $OP "$@" ;;
status)
wins="$(tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null || true)"
echo "=== tmux windows (session $TMUX_SES) ==="
[ -n "$wins" ] && echo "$wins" | sed 's/^/ /' || echo " (no $TMUX_SES session)"
echo "=== rooms (curl health per host-* window, Android-reliable) ==="
echo "$wins" | grep '^host-' | while read -r w; do
p="${w#host-}"; h="$(curl -s --max-time 3 "http://127.0.0.1:$p/health" 2>/dev/null || true)"
[ -n "$h" ] && echo " host :$p UP ($h)" || echo " host :$p down"
done
echo "$wins" | grep '^web-' | while read -r w; do
p="${w#web-}"; c="$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$p/" 2>/dev/null || true)"
[ "$c" = "200" ] && echo " web :$p UP" || echo " web :$p down"
done
echo "$wins" | grep -qE '^(host|web)-' || echo " (no rooms/web running)"
echo "=== operator daemons ($(basename "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}")/hh-bridge) ==="
_br="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/hh-bridge"
if [ -d "$_br" ] && ls -1 "$_br" >/dev/null 2>&1 && [ -n "$(ls -A "$_br" 2>/dev/null)" ]; then
for d in "$_br"/*/; do
[ -S "$d/control.sock" ] && echo " $(basename "$d") (socket live)" || echo " $(basename "$d") (stale — no socket)"
done
else echo " (no operator sessions)"; fi
;;
stop)
what="${1:-all}"; arg="${2:-}"
_kill_windows() { # <prefix> [port]
local pfx="$1" port="${2:-}"
local pat; [ -n "$port" ] && pat="^${pfx}-${port}$" || pat="^${pfx}-"
tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null | grep -E "$pat" | while read -r w; do
tmux kill-window -t "$TMUX_SES:$w" 2>/dev/null && echo "hh: stopped $w"
done
}
case "$what" in
host) _kill_windows host "$arg" ;;
web) _kill_windows web "$arg" ;;
join) $OP down 2>/dev/null && echo "hh: operator down" || echo "hh: no operator session" ;;
all)
_kill_windows host; _kill_windows web
$OP down 2>/dev/null && echo "hh: operator down" || true ;;
*) _die "usage: hh stop [all|host [PORT]|web [PORT]|join]" ;;
esac
;;
-h|--help|help) awk 'NR>1 && /^#/{sub(/^# ?/,"");print;next} NR>1{exit}' "${BASH_SOURCE[0]}" ;;
*) _die "unknown command '$cmd' (try: hh help)" ;;
esac
+22
View File
@@ -0,0 +1,22 @@
# hh-direnv.sh — source this from a directory's .envrc to auto-host a
# hack-house room whenever you `cd` in (direnv triggers it).
#
# # .envrc
# source "$HOME/hh-mobile/scripts/hh-direnv.sh"
#
# The room is named after the directory. Override before sourcing:
# export HH_PORT=8801 ; export HH_PASSWORD=hunter2
# source "$HOME/hh-mobile/scripts/hh-direnv.sh"
#
# Auto-host is ON by default; disable with `export HH_AUTOHOST=0` (env still set).
export HH_NAME="${HH_NAME:-$(basename "$PWD")}"
export HH_PORT="${HH_PORT:-8799}"
export HH_PASSWORD="${HH_PASSWORD:-hh-mobile}"
export HH_BIND="${HH_BIND:-0.0.0.0}"
if [ "${HH_AUTOHOST:-1}" = "1" ]; then
# idempotent: the launcher no-ops if the host window already exists
bash "$HOME/hh-mobile/scripts/hh" host "$HH_NAME" "$HH_PORT" >/dev/null 2>&1 || true
echo "hh: auto-hosting room '$HH_NAME' on :$HH_PORT (HH_AUTOHOST=0 to disable)" >&2
fi
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# phone-capture.sh — capture content FROM the Fairphone and pull it to the
# laptop. Runs on the LAPTOP/dev-box (it SSHes into the phone). Optionally
# ships the result to Telegram via the video-toolkit tg-send.sh.
#
# phone-capture.sh file <remote> [local] pull a file/dir (tar over ssh)
# phone-capture.sh term [tmux-session] capture tmux pane text (default: hh)
# phone-capture.sh shot [local.png] device screenshot via ADB *
# phone-capture.sh screen [secs] [local.mp4] device screen record via ADB *
# phone-capture.sh webshot <url> [local.png] screenshot a URL via headless chromium
#
# * ADB routes need Wireless Debugging on the phone (Termux can't reach
# SurfaceFlinger — app uid, non-root). If not connected, this prints the
# one-time pairing steps instead of failing.
#
# Add --tg [target] to any command to also send the artifact to Telegram
# (default target: andre). Env: PHONE_IP, PHONE_PORT, PHONE_USER, PHONE_KEY.
set -euo pipefail
IP="${PHONE_IP:-100.95.202.68}"; SSHPORT="${PHONE_PORT:-8022}"
USER_="${PHONE_USER:-u0_a203}"; KEY="${PHONE_KEY:-$HOME/.ssh/phone-deploy}"
SSH="ssh -i $KEY -p $SSHPORT -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new $USER_@$IP"
TG_SEND="$HOME/coding/video-toolkit/bin/tg-send.sh"
OUTDIR="${HH_CAPTURE_DIR:-$HOME/coding/_data/phone-captures}"
mkdir -p "$OUTDIR"
TS="$(date +%Y%m%d-%H%M%S)"
_die(){ echo "phone-capture: $*" >&2; exit 1; }
# strip a trailing "--tg [target]" off the args, remember it
TG=0; TGT="andre"
_args=(); while [ $# -gt 0 ]; do
case "$1" in
--tg) TG=1; shift; case "${1:-}" in ""|-*) : ;; *) TGT="$1"; shift;; esac ;;
*) _args+=("$1"); shift ;;
esac
done
set -- "${_args[@]:-}"
_reachable(){ timeout 8 bash -c "cat </dev/null >/dev/tcp/$IP/$SSHPORT" 2>/dev/null; }
_ship(){ # <file>
[ "$TG" = "1" ] || { echo "saved: $1"; return 0; }
[ -x "$TG_SEND" ] || _die "tg-send.sh not found at $TG_SEND"
echo "→ Telegram ($TGT): $1"; "$TG_SEND" "$1" "$TGT" "phone capture $TS"
}
# first fully-authorized adb device serial (wireless debugging uses a dynamic
# port, so don't assume :5555 — read whatever 'adb devices' actually shows)
_adb_serial(){ adb devices 2>/dev/null | awk '$2=="device"{print $1; exit}'; }
_adb_ready(){ [ -n "$(_adb_serial)" ]; }
_adb_help(){
cat >&2 <<EOF
ADB not connected. One-time setup on the phone (ports are shown on-device;
the IP can be the Tailscale IP $IP since adbd binds all interfaces):
1. Settings → System → Developer options → Wireless debugging: ON
2. Tap "Pair device with pairing code" — note the PAIR_PORT and 6-digit code
3. On the laptop: adb pair $IP:<PAIR_PORT> (enter the code)
4. Then: adb connect $IP:<DEBUG_PORT> (port under "Wireless debugging")
Re-run this command once 'adb devices' shows a 'device' line.
EOF
}
cmd="${1:-}"; shift || true
case "$cmd" in
file)
[ $# -ge 1 ] || _die "usage: file <remote> [local]"
rem="$1"; loc="${2:-$OUTDIR/$(basename "$rem")}"
_reachable || _die "phone offline (TCP $IP:$SSHPORT). Wake it, ensure sshd + termux-wake-lock."
# $rem substituted UNQUOTED so the phone's shell expands a leading ~ (avoid spaces in remote paths)
$SSH "cd \"\$(dirname $rem)\" && tar czf - \"\$(basename $rem)\"" | tar xzf - -C "$(dirname "$loc")"
echo "pulled $rem$loc"; _ship "$loc"
;;
term)
ses="${1:-hh}"; loc="$OUTDIR/phone-term-$ses-$TS.txt"
_reachable || _die "phone offline (TCP $IP:$SSHPORT)."
$SSH "tmux capture-pane -t '$ses' -p -S -2000 2>/dev/null" > "$loc" || _die "no tmux session '$ses' on phone"
echo "captured tmux '$ses' → $loc ($(wc -l < "$loc") lines)"; _ship "$loc"
;;
shot)
loc="${1:-$OUTDIR/phone-shot-$TS.png}"
_adb_ready || { _adb_help; exit 1; }
dev="$(_adb_serial)"
adb -s "$dev" exec-out screencap -p > "$loc"
[ -s "$loc" ] || _die "screencap produced no data"
echo "device screenshot → $loc"; _ship "$loc"
;;
screen)
secs="${1:-15}"; loc="${2:-$OUTDIR/phone-screen-$TS.mp4}"
_adb_ready || { _adb_help; exit 1; }
dev="$(_adb_serial)"
echo "recording ${secs}s of the device screen…"
adb -s "$dev" shell screenrecord --time-limit "$secs" /sdcard/hh-rec.mp4
adb -s "$dev" pull /sdcard/hh-rec.mp4 "$loc" >/dev/null && adb -s "$dev" shell rm -f /sdcard/hh-rec.mp4
echo "device screen recording → $loc"; _ship "$loc"
;;
webshot)
[ $# -ge 1 ] || _die "usage: webshot <url> [local.png]"
url="$1"; loc="${2:-$OUTDIR/phone-webshot-$TS.png}"
chrome="$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)"
[ -n "$chrome" ] || _die "no chromium/chrome found (or use the 'screenshot' Claude skill)"
"$chrome" --headless --disable-gpu --hide-scrollbars --window-size=430,932 \
--screenshot="$loc" "$url" >/dev/null 2>&1
[ -s "$loc" ] || _die "chromium produced no screenshot"
echo "web console screenshot ($url) → $loc"; _ship "$loc"
;;
-h|--help|help|"") awk 'NR>1 && /^#/{sub(/^# ?/,"");print;next} NR>1{exit}' "${BASH_SOURCE[0]}" ;;
*) _die "unknown command '$cmd' (try: help)" ;;
esac
+79
View File
@@ -0,0 +1,79 @@
#!/data/data/com.termux/files/usr/bin/bash
# termux-bootstrap.sh — one-shot on-device setup for the hack-house mobile
# operator. Run this IN TERMUX on the phone (not on the laptop):
#
# bash ~/hh-mobile/scripts/termux-bootstrap.sh
#
# It installs the deps that actually work on aarch64 Termux (cryptography from
# the package repo to dodge a rust build; pure/wheeled pip deps; NO `srp` — the
# pure-Python shim covers both client and server), holds a wake-lock so Android
# doesn't kill sshd, installs the `hh` alias, and runs the Phase-0 preflight.
#
# Idempotent: safe to re-run. Env: HH_ROOT (default ~/hh-mobile).
set -uo pipefail
ROOT="${HH_ROOT:-$HOME/hh-mobile}"
BASHRC="$HOME/.bashrc"
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
step(){ printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
[ -d "$ROOT" ] || { echo "hh-mobile tree not found at $ROOT — sync it first:"; \
echo " (laptop) tar czf - cmd_chat scripts requirements-operator.txt | \\"; \
echo " ssh -i ~/.ssh/phone-deploy -p 8022 u0_a203@100.95.202.68 'mkdir -p ~/hh-mobile && tar xzf - -C ~/hh-mobile'"; \
exit 1; }
step "Termux packages"
# python-cryptography from the repo avoids compiling the rust cryptography wheel
for p in python tmux git python-cryptography openssh; do
if dpkg -s "$p" >/dev/null 2>&1 || pkg list-installed 2>/dev/null | grep -q "^$p/"; then
ok "$p present"
else
warn "installing $p"; yes | pkg install "$p" >/dev/null 2>&1 && ok "$p installed" || warn "$p install FAILED (continuing)"
fi
done
step "Python operator deps (pure/wheeled — no srp C-ext)"
_py_has(){ python -c "import $1" >/dev/null 2>&1; }
if _py_has requests && _py_has rich && _py_has websockets; then
ok "requests rich websockets importable"
else
warn "installing requests rich websockets…"
pip install --quiet requests rich websockets && ok "pip deps installed" || warn "pip install had errors"
fi
if python -c "from cryptography.fernet import Fernet; Fernet(Fernet.generate_key())" >/dev/null 2>&1; then
ok "cryptography (Fernet) importable"
else
warn "cryptography import FAILED — run: pkg install python-cryptography"
fi
step "Wake-lock (keep Termux/sshd alive when backgrounded)"
if command -v termux-wake-lock >/dev/null 2>&1; then
termux-wake-lock && ok "wake-lock held" || warn "wake-lock failed"
else
warn "termux-wake-lock not found (install Termux:API for it) — Android may kill sshd"
fi
step "hh launcher alias"
if grep -q "alias hh=" "$BASHRC" 2>/dev/null; then
ok "alias already in $BASHRC"
else
printf "\n# hack-house mobile launcher\nalias hh='bash \"%s/scripts/hh\"'\n" "$ROOT" >> "$BASHRC"
ok "added 'hh' alias to $BASHRC (run: source $BASHRC)"
fi
step "Phase-0 preflight"
if [ -f "$ROOT/scripts/termux-preflight.py" ]; then
python "$ROOT/scripts/termux-preflight.py" || warn "preflight reported issues (see above)"
else
warn "scripts/termux-preflight.py not in the synced tree — skipped"
fi
step "Next steps"
cat <<EOF
source $BASHRC # activate the 'hh' alias now
hh host # host a room on this phone (:8799, password hh-mobile)
hh web # mobile web console (:8790)
hh status # what's running
See docs/mobile-quickstart.md for the full grammar.
EOF
+42
View File
@@ -134,6 +134,48 @@ 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 `/ai <name> !<task>` and read its reply — cheaper than doing trivial work
yourself. (Direct operator→Ollama delegation is a later phase.) 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):
```bash
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`:**
```bash
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`** — `$TMPDIR` is `$HOME/.claude-temp`; `$PREFIX` =
`/data/data/com.termux/files/usr`.
- `rustc`/`openssl` binaries absent by default; `git/clang/make/pkg-config` present.
- Deps: `pkg install python-cryptography` (avoids a rust build); `pip install
requests rich websockets`; **skip `srp`** → 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 ## Safety
- One bad action's blast radius is the container; `local` exec is opt-in only. - One bad action's blast radius is the container; `local` exec is opt-in only.
+79
View File
@@ -107,3 +107,82 @@ def test_pure_client_full_http_handshake(test_client):
assert usr.authenticated(), "server accepted us but H_AMK did not match" assert usr.authenticated(), "server accepted us but H_AMK did not match"
assert verify["ws_token"], "no ws_token issued — cannot join the room" assert verify["ws_token"], "no ws_token issued — cannot join the room"
# --- Inverse direction: the pure *Verifier* (server side) --------------------
# When the server itself runs on Termux (no srp C-extension — e.g. a room HOSTED
# ON THE PHONE) srp_auth.py falls back to pure.Verifier. These tests exercise
# that server surface, so a broken constant in the shim's Verifier can't ship.
def test_real_user_authenticates_against_pure_verifier():
"""A real srp.User (C-ext client, e.g. a laptop) must authenticate against
the pure Verifier — the exact cross-implementation handshake proven live
when the laptop joined a phone-hosted room."""
salt, vkey = srp.create_salted_verification_key(
b"chat", PASSWORD, hash_alg=srp.SHA256
)
usr = srp.User(b"chat", PASSWORD, hash_alg=srp.SHA256)
_, A = usr.start_authentication()
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
s, B = svr.get_challenge()
assert B is not None, "pure Verifier aborted the SRP-6a safety check"
M = usr.process_challenge(s, B)
assert M is not None, "real client failed the SRP-6a safety checks"
H_AMK = svr.verify_session(M)
assert H_AMK is not None, "pure Verifier rejected the real client's proof M1"
usr.verify_session(H_AMK)
assert usr.authenticated(), "real client rejected the pure Verifier's H_AMK"
assert svr.authenticated()
# Both sides must derive the identical session key or encryption breaks.
assert usr.get_session_key() == svr.get_session_key()
def test_pure_user_authenticates_against_pure_verifier():
"""Both ends on Termux (pure client + pure Verifier) — a room hosted and
joined entirely without the C-extension must still complete the handshake."""
salt, vkey = pure.create_salted_verification_key(
b"chat", PASSWORD, hash_alg=pure.SHA256
)
usr = pure.User(b"chat", PASSWORD, hash_alg=pure.SHA256)
_, A = usr.start_authentication()
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
s, B = svr.get_challenge()
assert B is not None
M = usr.process_challenge(s, B)
assert M is not None
H_AMK = svr.verify_session(M)
assert H_AMK is not None
usr.verify_session(H_AMK)
assert usr.authenticated()
assert svr.authenticated()
assert usr.get_session_key() == svr.get_session_key()
def test_pure_verifier_rejects_wrong_password():
"""Negative control for the pure Verifier: a bad password must never
authenticate, proving the positive tests aren't passing trivially."""
salt, vkey = srp.create_salted_verification_key(
b"chat", PASSWORD, hash_alg=srp.SHA256
)
usr = srp.User(b"chat", b"wrongpassword", hash_alg=srp.SHA256)
_, A = usr.start_authentication()
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
s, B = svr.get_challenge()
M = usr.process_challenge(s, B)
assert svr.verify_session(M) is None
assert not svr.authenticated()
assert svr.get_session_key() is None