Files
ai-app/AGENTS.md
T
irisandClaude Fable 5 ce349af414 Drop the in-repo state fallback
Nothing has run against the host's backend yet, so there is no old config
or transcript to keep working -- the XDG paths are simply where state
lives. Removing the fallback takes repo_root() with it, since finding the
repo from the running executable existed only to locate that legacy state.

AGENTS.md gets the arrangement that replaced it: the host builds and runs
from its own clone outside the shared mount, and code reaches it by push
to gitea, which this VM's key is not authorized for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 04:01:37 -04:00

9.2 KiB
Raw Blame History

ai-app

A phone interface to AI coding sessions (Claude Code and llama.cpp via pi), replacing the Claude app for daily use. Rust/Axum backend on the desktop, Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token between them.

PLAN.md is the design source of truth. Read it before building or changing anything structural. It records every decision with its date, its rationale, and the alternatives that were rejected and why — keep that habit when a decision changes: update the plan in place, don't let this file and the plan drift into two versions of the truth. This file is the working notes layer: conventions, commands, and things that have bitten.

The central design point, worth not undoing by accident: a session is a child process speaking JSONL over stdio, translated into one common event model. Claude Code (stream-json) and pi (RPC mode) are two translators behind one Driver trait; the transcript, the SSE stream, the phone UI, and SSH spawning (the same command wrapped in ssh host …) all work purely in the common model. A new session type is a new driver — never a session-type branch in shared code (routes, transcript, app screens).

Layout

Mirrors ../local-updater deliberately — same stack (axum 0.8 + axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single :androidApp module), same cert scheme, same registry pattern (every session mutation funnels through the manager so in-memory and on-disk state can't come apart). Read local-updater's README.md and AGENTS.md for the conventions before diverging from them; module-by-module intent for this repo is in PLAN.md's "Backend layout" section.

  • server/ — Rust backend (ai-server). main.rs bootstraps (TLS, the auth layer, token/QR enrollment, wg0 binding), routes.rs has the HTTP table in its module doc comment, auth.rs the bearer-token middleware, config.rs the persisted schema, session/ the manager (registry pattern), Driver trait + event model, EchoDriver, and transcripts.
  • app/ — Compose Android app, single :androidApp module, package com.example.aiapp, label "AI Sessions". AppRoot.kt is the navigation when; Api.kt/EventStream.kt the REST + SSE clients; Events.kt the event model mirror; ServerConfig.kt settings + Keystore-sealed token; screens in SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen.
  • gen-dev-cert.sh / certs/ — copied from local-updater's scheme (idempotent CA, reissued leaf; regenerating the CA strands the installed app — same one-way door). Dev SANs cover 127.0.0.1, 10.0.2.2 (emulator → host), and the LAN IP alongside the WireGuard address.

Status

Phases 13 done 2026-08-24 (see PLAN.md's phase list for what each verified): the skeleton pipe, the full Claude driver (streaming, tools, permission + AskUserQuestion cards, steering, interrupt, --resume crash recovery, images both ways), and the usage screen. Phase 4 (pi/llama.cpp) is deferred — not testable in this VM. Next: phase 5 (SSH; needs a decision on how to test — no keys in ~/.ssh here) and real-phone/WireGuard bring-up, which is operational rather than code.

Checking your work

  • Server: ./run-tests.sh (or cargo test) + cargo clippy --all-targets from server/ — the build stays warning-clean, keep it that way.
  • App: from app/, . ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin; ./run-android.sh builds, installs, and launches on the emulator.
  • Run the server for development with --bind 127.0.0.1 (wg0 doesn't exist on this machine yet; the default fails closed). First run prints the enrollment QR/URI with the token — capture it from the log.
  • Prefer exercising the server directly over going through the UI: curl --cacert certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions. The emulator app reaches it at https://10.0.2.2:8443; enroll it with adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'" (quote so the device shell doesn't eat the &s).

Where things run (host vs this VM)

Established 2026-08-25, and it decides more than it looks like:

  • The host (192.168.1.168) is the backend machine. It runs local-updater's server today and is where ai-server belongs in production: it has the LAN address the phone can reach, and it's where WireGuard terminates. wg-setup-host.sh sets that up (keys, wg0.conf, the phone's QR); run it there with sudo WG_ENDPOINT=<ddns name>.
  • This VM is a dev sandbox behind qemu user-mode networking (10.0.2.15, gateway 10.0.2.2) — outbound only. The host is reachable at 10.0.2.2, but nothing outside can initiate a connection into the VM, so the tunnel and the real phone can never terminate here.
  • Code reaches the host through gitea, not the shared mount. This VM's checkout (~/host/repos/ai-app, a virtiofs mount the host also sees at ~/stuff/vm/ai/repos/ai-app) is a working copy only: its origin is git@git.arirex.me:iris/ai-app, and the VM's key is not authorized for it — pushing from here fails with Permission denied (publickey). The host pushes, and its own separate clone — outside the shared mount — is what gets built and run. So the review at push time, not a filesystem permission, is what keeps VM-authored code off the host.
  • Consequence for building here: server/target/ is shared with the host's view of this checkout, so if anything on the host ever builds from the shared path, the two cargo builds replace each other's binary and each rebuilds from scratch. Building on the host from its own clone avoids it entirely.
  • wg0 (10.66.0.1) now exists in this VM too, so the production path — ai-server with no --bind — is exercisable during development. It has no reachable peer and doesn't need one; the interface existing is what the server requires. Consequence: with no --bind, the emulator can't reach the server (it dials 10.0.2.2), so keep using --bind 127.0.0.1 for app work.
  • ./test-wg-tunnel.sh up|test|down builds a real tunnel between two network namespaces inside one machine and drives the server through it — a genuine handshake against 10.66.0.1 with pinned TLS, no router or phone involved. That's the way to verify the wg0-only posture.
  • The claude CLI is installed in this VM only, not on the host. So the backend reaches it the same way it would any other machine: a configured host, and a session that names it. For the host to ssh in, the VM needs an inbound port forward in its launch configuration (qemu hostfwd) — usermode networking has none by default.
  • Nothing secret goes in the repo. The VM is treated as untrusted (see PLAN.md's security section), and the repo is shared read-write with the host, so state lives outside it: $XDG_CONFIG_HOME/ai-app/config.json and certs/, $XDG_DATA_HOME/ai-app/sessions/, owner-only.
  • Certificates are generated on the machine that serves them (./gen-dev-cert.sh, honours AI_APP_CERTS). Running it in the VM makes a separate throwaway dev CA for emulator work — never install a build pinning that on the real phone.
  • Point development at a scratch state directory rather than the real one: --config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444, or XDG_CONFIG_HOME=… XDG_DATA_HOME=….

Things that have bitten

  • tracing caches callsite interest process-wide. A test that hits a tracing::warn! with no subscriber installed can poison the interest cache for a concurrent test that captures logs (flaky "nothing was logged" failures). Keep every exercise of a logging code path under the one capturing subscriber — that's why the auth middleware has a single combined gating+logging test.
  • The keyboard pans the window unless the activity opts into resize. Without android:windowSoftInputMode="adjustResize", opening the IME slides the whole window up (top bar off screen) instead of resizing — imePadding() alone doesn't fix it and the transcript looks empty.
  • CMP 1.11 deprecates the compose.* dependency accessors — declare org.jetbrains.compose.<x>:<x> directly (material3 has its own release train, separate from the CMP version).

Environment notes (this machine, learned in local-updater)

  • Android SDK is at ~/Android/Sdk, not the root-owned /opt/android-sdk the ambient $ANDROID_HOME may point at; copy local-updater's android-env.sh override pattern.
  • Each agent command runs in a fresh shell — exported environment does not carry over. Chain: cd app && . ./android-env.sh && ./gradlew …. Never pipe source into head/grep (subshell discards the exports).
  • The shared emulator AVD is named tdep — one emulator across the Android projects on this machine, not one per repo.
  • Long-running servers launched from an agent must be fully detached (setsid nohup … & disown -h, verify PPID 1), and every pgrep -f/pkill -f pattern needs its first character bracketed ([a]i-server) in every occurrence in the command, or the pattern matches the shell running it. Full explanation in local-updater's AGENTS.md — it bites exactly the same way here.