ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/bin/sh
|
||||
# An ai-server with invented sessions in it, for driving the phone UI.
|
||||
#
|
||||
# The import screen lists whatever Claude Code has on the machine, and in
|
||||
# this VM that is real agent transcripts -- so exercising *delete* against
|
||||
# the ordinary server means deleting somebody's conversation, and exercising
|
||||
# *import* means starting a real `claude --resume` on the owner's account. Both
|
||||
# are the wrong price for looking at a list.
|
||||
#
|
||||
# So this starts a second server that can see neither. `$HOME` is pointed at
|
||||
# a sandbox directory, which is the only thing the importer's own script
|
||||
# consults (`$HOME/.claude/projects/*/*.jsonl`), and the config and session
|
||||
# data live there too. What it lists is invented here, and deleting all of
|
||||
# it costs nothing.
|
||||
#
|
||||
# Three things are deliberately shared with the real server, because the
|
||||
# installed APK is built against them: the TLS certificates (the app pins
|
||||
# that CA and would refuse a fresh one) and the port. Run it while the real
|
||||
# server is down.
|
||||
#
|
||||
# Usage:
|
||||
# ./ui-sandbox.sh start it, print the enrolment command
|
||||
# ./ui-sandbox.sh stop stop it
|
||||
#
|
||||
# Environment: AI_SANDBOX_ROOT, AI_SANDBOX_TOKEN, AI_SANDBOX_PORT, and
|
||||
# AI_SANDBOX_DELAY -- the last being the server's own `--delay`, which is
|
||||
# what makes a spinner visible at all. On loopback every request is back in
|
||||
# under a millisecond, so a busy state that is correct is still a busy state
|
||||
# nobody can see.
|
||||
set -eu
|
||||
|
||||
ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox}
|
||||
TOKEN=${AI_SANDBOX_TOKEN:-sandbox}
|
||||
PORT=${AI_SANDBOX_PORT:-8443}
|
||||
DELAY=${AI_SANDBOX_DELAY:-1200}
|
||||
CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR=$SCRIPT_DIR/../server
|
||||
PIDFILE=$ROOT/server.pid
|
||||
LOG=$ROOT/server.log
|
||||
|
||||
# By pid rather than by pattern: a `pkill -f` for something as generic as
|
||||
# "ai-server" also matches the shell running this script, which kills the
|
||||
# script mid-flight and leaves the restart never having happened.
|
||||
stop_server() {
|
||||
[ -f "$PIDFILE" ] || return 0
|
||||
pid=$(cat "$PIDFILE")
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
echo "sandbox: stopped server $pid"
|
||||
fi
|
||||
rm -f "$PIDFILE"
|
||||
}
|
||||
|
||||
if [ "${1:-start}" = stop ]; then
|
||||
stop_server
|
||||
exit 0
|
||||
fi
|
||||
|
||||
stop_server
|
||||
rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron"
|
||||
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
|
||||
mkdir -p "$PROJECTS" "$ROOT/sessions"
|
||||
|
||||
# Eight of them, because the point of the screen is a list long enough that
|
||||
# picking rows one at a time is the annoyance being fixed. Ids are the same
|
||||
# shape the CLI writes (a uuid, and the file name *is* the session id), and
|
||||
# each carries a `cwd` and a few user turns so the row has a title, a path
|
||||
# and a line count to show.
|
||||
i=1
|
||||
while [ "$i" -le 8 ]; do
|
||||
id="0000000${i}-5eed-4a11-9c0d-000000000${i}00"
|
||||
file=$PROJECTS/$id.jsonl
|
||||
cwd="/home/bob/repos/sandbox/project-$i"
|
||||
: >"$file"
|
||||
turn=1
|
||||
while [ "$turn" -le $((i + 2)) ]; do
|
||||
printf '{"type":"user","cwd":"%s","message":{"role":"user","content":[{"type":"text","text":"sandbox session %s, turn %s"}]}}\n' \
|
||||
"$cwd" "$i" "$turn" >>"$file"
|
||||
turn=$((turn + 1))
|
||||
done
|
||||
# A usage record on the last line, which is where the importer reads the
|
||||
# context figure from. Left off two of them on purpose: "no turn has
|
||||
# recorded any" is a state the row has to be able to show, and a list
|
||||
# where every row has a number never exercises it.
|
||||
if [ "$i" -ne 3 ] && [ "$i" -ne 6 ]; then
|
||||
printf '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":%s,"output_tokens":128}}}\n' \
|
||||
"$((i * 9000))" >>"$file"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# A CLI that does nothing, so importing one of these is free and safe.
|
||||
# Everything the spawn path cares about is here: it holds the fifo open,
|
||||
# records a real pid, writes nothing, and dies on a signal. A real
|
||||
# `claude --resume` against an invented session id would either fail in a
|
||||
# way that tests nothing or start a turn on somebody's account.
|
||||
cat >"$ROOT/fake-claude" <<'FAKE'
|
||||
#!/bin/sh
|
||||
cat > /dev/null
|
||||
FAKE
|
||||
chmod +x "$ROOT/fake-claude"
|
||||
|
||||
hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
|
||||
cat >"$ROOT/config.ron" <<RON
|
||||
tokens: [
|
||||
(
|
||||
name: "sandbox",
|
||||
sha256: "$hash",
|
||||
),
|
||||
],
|
||||
setups: [
|
||||
(
|
||||
id: "local",
|
||||
name: "sandbox",
|
||||
providers: [
|
||||
(
|
||||
name: "echo",
|
||||
kind: echo,
|
||||
),
|
||||
(
|
||||
name: "claude-cli",
|
||||
kind: claude_cli,
|
||||
command: "$ROOT/fake-claude",
|
||||
models: [
|
||||
"haiku",
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
sessions: [],
|
||||
RON
|
||||
|
||||
echo "sandbox: building"
|
||||
(cd "$SERVER_DIR" && cargo build --quiet)
|
||||
|
||||
# Fully detached, so it outlives the shell that started it. HOME is the
|
||||
# whole isolation: the importer's script reads it, and nothing else here
|
||||
# looks outside the paths passed explicitly below.
|
||||
HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
|
||||
--bind 127.0.0.1 \
|
||||
--port "$PORT" \
|
||||
--config "$ROOT/config.ron" \
|
||||
--data-dir "$ROOT/sessions" \
|
||||
--models-dir "$ROOT/models" \
|
||||
--certs "$CERTS" \
|
||||
--delay "$DELAY" \
|
||||
>"$LOG" 2>&1 &
|
||||
pid=$!
|
||||
disown -h "$pid" 2>/dev/null || true
|
||||
echo "$pid" >"$PIDFILE"
|
||||
|
||||
# Waited for rather than assumed: the enrolment below fails silently against
|
||||
# a server that has not bound yet, and the app then shows a network error
|
||||
# that has nothing to do with what is being tested.
|
||||
tries=0
|
||||
while [ "$tries" -lt 50 ]; do
|
||||
if grep -q "listening\|Listening" "$LOG" 2>/dev/null; then break; fi
|
||||
kill -0 "$pid" 2>/dev/null || { echo "sandbox: server exited; see $LOG" >&2; tail -5 "$LOG" >&2; exit 1; }
|
||||
tries=$((tries + 1))
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
cat <<INFO
|
||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
||||
sandbox: 8 invented Claude Code sessions under $PROJECTS
|
||||
|
||||
enrol the emulator:
|
||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'"
|
||||
|
||||
stop it:
|
||||
./ui-sandbox.sh stop
|
||||
INFO
|
||||
Reference in new issue
Block a user