Files
ai-app/app/debug-transcript.sh
T

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"
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 <<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