Files
ai-app/app/debug-transcript.sh
T
iris b172c464ea 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.
2026-08-31 20:29:07 -04:00

150 lines
6.6 KiB
Bash
Executable File

#!/bin/sh
# Puts a real Claude Code conversation on the emulator, for looking at the
# transcript screen under content it was not written against.
#
# The echo driver's fixtures (`/mixed`, `/stream`) are the right rig for most
# things and the wrong one for anything whose cost scales with what was
# actually written: a real reply is longer, is real markdown, and carries tool
# calls whose input and output are kilobytes rather than a word. Two faults
# were invisible until a real transcript was loaded -- a page of history
# landing mid-fling threw the reader back to the newest end, and parsing one
# real reply took 51ms against 4.6ms for a synthetic one.
#
# ./debug-transcript.sh # newest transcript in ~/.claude/projects
# ./debug-transcript.sh dev-updater # newest one whose project path matches
# ./debug-transcript.sh -b dev-updater # the biggest one instead of the newest
# ./debug-transcript.sh -d 350 # hold every response back 350ms
#
# **The transcript never enters the repository.** These files are private --
# they hold whatever was said, read and written in that session -- so this
# copies one into /tmp and points an isolated server at it. Nothing it makes
# is committed, and ~/repos is shared with the host besides.
#
# What it builds, all of it disposable:
# /tmp/ai-app-debug/home a HOME holding only the copied transcript, so
# the import cannot see or resume a live session
# /tmp/ai-app-debug/sessions that server's own data directory
# a server on PORT, with its own config and the real CA (so the installed
# APK, which pins the CA of the machine that built it, still trusts it)
set -eu
PORT="${PORT:-8455}"
DELAY=0
MATCH=""
BIGGEST=""
STOP=""
while [ $# -gt 0 ]; do
case "$1" in
-d|--delay) DELAY="$2"; shift 2 ;;
-b|--biggest) BIGGEST=yes; shift ;;
--stop) STOP=yes; shift ;;
-h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) MATCH="$1"; shift ;;
esac
done
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
REPO=$(dirname "$SCRIPT_DIR")
WORK=/tmp/ai-app-debug
PROJECTS="$HOME/.claude/projects"
# Whatever the last run left, before this one takes the port again.
#
# Importing spawns `claude --resume` so the conversation can be continued, and
# those outlive the server that started them: twelve accumulated over one
# afternoon of re-running this. They are found by the scratch HOME and nothing
# else, because every other `claude` on this machine is somebody's live session
# -- including the one that may be running this script.
stop_previous() {
for pid in $(pgrep -x claude 2>/dev/null); do
home=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^HOME=//p')
if [ "$home" = "$WORK/home" ]; then kill "$pid" 2>/dev/null || true; fi
done
pkill -f "[a]i-server --bind 127.0.0.1 --port $PORT" 2>/dev/null || true
# Gone, not merely signalled: the next start binds the same port.
while pgrep -f "[a]i-server --bind 127.0.0.1 --port $PORT" >/dev/null 2>&1; do sleep 1; done
}
stop_previous
if [ -n "$STOP" ]; then
echo "Stopped the debug server on port $PORT and anything it spawned."
exit 0
fi
# Newest first, so with no argument you get the conversation you were just in.
# `--biggest` is the other question worth asking of this directory, and the one
# a scrolling test wants: the longest conversation on the machine is the one
# with enough rows to page backwards through, and the newest is routinely a
# session five minutes old with nothing in it.
if [ -n "$BIGGEST" ]; then
SRC=$(ls -S "$PROJECTS"/*"$MATCH"*/*.jsonl 2>/dev/null | head -1)
else
SRC=$(ls -t "$PROJECTS"/*"$MATCH"*/*.jsonl 2>/dev/null | head -1)
fi
if [ -z "$SRC" ]; then
echo "No Claude Code transcript under $PROJECTS matching '${MATCH:-anything}'." >&2
echo "Sessions are written there as <encoded-cwd>/<session-id>.jsonl." >&2
exit 1
fi
ID=$(basename "$SRC" .jsonl)
PROJECT=$(basename "$(dirname "$SRC")")
echo "==> Using $PROJECT/$ID ($(wc -l < "$SRC") lines, $(du -h "$SRC" | cut -f1))"
# A HOME of its own is the isolation: `import::list` enumerates
# "$HOME"/.claude/projects/*/*.jsonl through the transport, so a server started
# with this one can only ever see the copy. That matters for more than tidiness
# -- importing spawns `claude --resume <id>`, and against the real file that
# would be a second CLI writing to a conversation somebody may still be in.
rm -rf "$WORK"
mkdir -p "$WORK/home/.claude/projects/$PROJECT"
cp "$SRC" "$WORK/home/.claude/projects/$PROJECT/$ID.jsonl"
CERTS="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs"
if [ ! -f "$CERTS/ca.pem" ]; then
echo "No CA at $CERTS/ca.pem -- start ai-server once normally first." >&2
exit 1
fi
SERVER="$REPO/server/target/debug/ai-server"
[ -x "$SERVER" ] || (cd "$REPO/server" && cargo build)
echo "==> Starting server on port $PORT (delay ${DELAY}ms)"
HOME="$WORK/home" setsid nohup "$SERVER" \
--bind 127.0.0.1 --port "$PORT" \
--config "$WORK/config.ron" --data-dir "$WORK/sessions" --certs "$CERTS" \
--delay "$DELAY" >"$WORK/server.log" 2>&1 </dev/null &
until grep -q "serving https" "$WORK/server.log" 2>/dev/null; do sleep 1; done
TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f2)
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
echo "==> Importing"
SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
SESSION=$(api -H 'Content-Type: application/json' -X POST \
"https://127.0.0.1:$PORT/sessions" \
-d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
# 10.0.2.2 is the emulator's route to this VM's loopback. The `&` are quoted
# on the *device* side: adb runs its argument through a shell there, which
# would otherwise cut the URI at the first one and enrol with no token.
if command -v adb >/dev/null 2>&1 && [ -n "$(adb devices | awk '$2=="device"{print $1}')" ]; then
echo "==> Enrolling the app"
adb shell "am start -a android.intent.action.VIEW \
-d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'" >/dev/null
fi
cat <<EOF
Ready. Open "$PROJECT" in the app; press Refresh if the list is stale.
token $TOKEN
log $WORK/server.log
stop $SCRIPT_DIR/debug-transcript.sh --stop
The imported session is a claude-cli one, so **do not send it a message**:
that continues a real conversation on a real account. It is for reading.
EOF