Clearing out imported sessions was one confirmation dialog per row, which is why it was not worth doing. Holding a row on the import screen now selects it and plain taps add more; Delete and Import act on the whole selection from a bar along the bottom. Submitting hands the work over and puts the screen back as it was: the selection clears, the bar goes, and what says the work is happening is the rows it is happening to -- the one in flight marked with its operation, the rest marked "waiting". Both are inert, so a queued row cannot be tapped into starting a second CLI behind the batch already coming for it. Rows leave as each one lands rather than all at the end, because a finished row still sitting there looks exactly like one that was never imported; the rows below it therefore move, so a row that has just moved ignores taps for half a second. That busy appearance is one composable shared with the session list, which had its own dimmed row and its own word for it. It is a word rather than a bare spinner because deleting and importing differ in kind. Deleting a session can now take the machine's own transcript with it, as a switch in the confirmation and only where the driver keeps a record this app's delete cannot otherwise reach. Off by default, since leaving that copy is what makes an ordinary delete recoverable -- and the paragraph is rewritten rather than appended to when it is on, because the sentence promising the conversation is still there to import again is exactly the one the switch makes false. The server removes the machine's copy first, so a machine it cannot reach leaves the session where it was. `app/ui-sandbox.sh` is how all of this was driven: a second server with its own $HOME, invented transcripts and a two-line `claude`. Against the ordinary server, testing delete deletes somebody's conversation and testing import spends a turn on a real account.
176 lines
6.1 KiB
Bash
Executable File
176 lines
6.1 KiB
Bash
Executable File
#!/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 Bryan'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
|