#!/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 /.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 `, 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; 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" MACHINE=$(api "https://127.0.0.1:$PORT/machines" | 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 "{\"machine\":\"$MACHINE\",\"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 <