This document explains how the Pi Mobile project is organized, how data flows through the system, and where to make changes safely.
For visual system diagrams, see Architecture (Mermaid diagrams). For durable decision rationale, see Architecture Decision Records.
- System Overview
- Repository Layout
- Module Responsibilities
- Key Runtime Flows
- Bridge Control Model
- State Management in Android
- Testing Strategy
- Common Change Scenarios
- Reference Files
Android App (Compose)
│ WebSocket (envelope: { channel, payload })
▼
Bridge (Node.js)
│ stdin/stdout JSON RPC
▼
pi --mode rpc
+ internal extensions (pi-mobile-tree, pi-mobile-open-stats)
The app never talks directly to a pi process. It talks to the bridge, which:
- handles auth and client identity
- manages one pi subprocess per cwd
- enforces single-client control lock per cwd/session
- forwards RPC events and bridge control messages
This retained boundary is documented in ADR-0004: one isolated pi --mode rpc process per cwd.
| Path | Purpose |
|---|---|
app/ |
Android UI, view models, host/session UX |
core-rpc/ |
Kotlin RPC command/event models and parser |
core-net/ |
WebSocket transport, envelope routing, reconnect/resync |
core-sessions/ |
Session index models, cache, repository logic |
bridge/ |
Node bridge server, protocol, process manager, extensions |
benchmark/ |
Android macrobenchmark module and baseline-profile scaffolding |
docs/ |
Human-facing project docs |
docs/ai/ |
Planning/progress artifacts |
- Compose screens and overlays
ChatViewModel: chat timeline, command palette, extension dialogs/widgets, tree/stats/model sheetsRpcSessionController: high-level session operations backed byPiRpcConnection- Host management and token storage
RpcCommandsealed models for outgoing commandsRpcIncomingMessagesealed models for incoming events/responsesRpcMessageParsermapping wiretype→ typed event classes
WebSocketTransport: reconnecting socket transport with outbound queuePiRpcConnection:- wraps socket messages in envelope protocol
- routes bridge vs rpc channels
- performs handshake (
bridge_hello, cwd set, control acquire) - exposes
rpcEvents,bridgeEvents, andresyncEvents
- Host-scoped session index state and filtering
- cache-first rendering with background refresh
- per-host refresh coalescing, throttling, and failure backoff
- in-memory and file cache implementations
server.ts: WebSocket server, token validation, protocol dispatch, health endpointprocess-manager.ts: per-cwd forwarders + control locksrpc-forwarder.ts: pi subprocess lifecycle/restart/backoffsession-indexer.ts: streaming JSONL indexing with shared revision snapshots, bounded tail freshness, tree caches, and bounded evictionextensions/: internal mobile bridge extensions
- App creates
PiRpcConnectionConfig(url,token,cwd,clientId). - Bridge returns
bridge_hello; the app sets cwd and acquires control. - Before a retained Chat destination can render,
RpcSessionControllerpublishes a new active-session generation withisSwitching = true, so the previous transcript is hidden immediately. - For a selected session, the controller sends documented
switch_session, refreshes the authoritative active path, resets its entry projection, and publishes the settled generation. - Controller-owned bootstrap stages
get_statefirst, then uses the currentget_entriesprojection. It falls back to documentedget_messagesonly when no safe projection is available. ChatViewModeltags bootstrap and tree work with the generation and rejects late results after another switch.
- User sends prompt from
ChatViewModel RpcSessionController.sendPrompt()sendsprompt- Bridge forwards RPC payload to active cwd process
- pi emits streaming events (
message_update, tool events,agent_end, etc.) ChatViewModelupdates timeline and streaming state
WebSocketTransport auto-reconnects with exponential backoff.
On reconnect, PiRpcConnection:
- waits for new
bridge_hello - re-acquires cwd/control if needed
- emits
RpcResyncSnapshotafterget_state + get_entries, using the last entry ID as a reconnect cursor
This keeps timeline and streaming flags consistent after network interruptions.
Tree display and navigation have separate authority:
- The active session uses Pi's documented
get_tree. The app may show its last cached result as stale while an authoritative refresh runs in the background. - Inactive-session browsing uses the bridge's validated, revision-keyed JSONL tree cache.
- Session switches cancel or generation-reject late tree results.
Navigation remains the one internal-extension path because Pi 0.80.6 has no tree-navigation RPC command:
- App sends
bridge_navigate_tree { entryId }. - Bridge validates cwd/control and the requested entry ID.
- Bridge sends RPC
promptwith/pi-mobile-tree <entryId> <statusKey>. - Extension emits
setStatus(statusKey, JSON payload). - Bridge returns a sanitized
bridge_tree_navigation_result. - App updates the editor draft and refreshes the authoritative tree.
Bridge-observed mutations push bridge_session_invalidated for immediate cursor resync. To cover direct terminal or other external edits, ChatViewModel also performs one safety freshness check every 60 seconds only while Chat is foreground-active.
The bridge computes a sanitized fingerprint from file revision and bounded parse/tail data. The app classifies a changed fingerprint as follows:
- inside the local mutation grace window: update the baseline;
- no explicit other-client owner while idle: refresh silently;
- no explicit other-client owner while busy: defer refresh until idle;
- explicit other-client lock ownership: show one throttled, actionable conflict with Sync now;
- reload failure: show an actionable recovery error.
Unknown cursors, branch moves, session replacement, unsupported entries, or invalid projections trigger exactly one explicit full rebuild. The app never treats a fingerprint change alone as proof of a conflict.
The bridge uses lock ownership to prevent conflicting writers.
- Lock scope: cwd (and optional sessionPath)
- Only lock owner can send RPC traffic for that cwd
- Non-owner receives
bridge_error(control_lock_requiredorcontrol_lock_denied)
This protects session integrity when multiple mobile clients are connected.
Primary state owner: ChatViewModel (StateFlow<ChatUiState>).
Important sub-states:
- connection + streaming state
- timeline (windowed history + realtime updates)
- command palette and slash command metadata
- extension dialogs/notifications/widgets/title
- bash dialog state
- stats/model/tree bottom-sheet state
- deferred freshness refresh, explicit-owner conflict, and sync-in-progress state
High-level design:
- transport/network concerns stay in
core-net+RpcSessionController - rendering concerns stay in Compose screens
- event-to-state logic stays in
ChatViewModel
- ViewModel-focused unit tests in
app/src/test/... - Covers command filtering, extension workflow handling, timeline behavior, queue semantics
- Vitest suites under
bridge/test/... - Covers auth, malformed payloads, control locks, reconnect, tree navigation, health endpoint
# Complete Android non-device gate
./gradlew clean ktlintCheck detekt test :benchmark:compileBenchmarkKotlin :app:lintDebug :app:assembleDebug :app:assembleRelease
./gradlew :app:compileDebugAndroidTestKotlin
# Bridge quality and production dependency gate
(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod)- Add command model in
core-rpc/RpcCommand.kt - Add encoder mapping in
core-net/RpcCommandEncoding.kt - Add controller method in
RpcSessionController - Call from ViewModel/UI
- Add tests in app + bridge (if bridge control involved)
- Add message handling in
bridge/src/server.ts - Add payload parser/use site in Android (
PiRpcConnection.requestBridgecaller) - Add protocol docs in
docs/bridge-protocol.md - Add tests in
bridge/test/server.test.ts
Follow docs/extensions.md checklist.
app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.ktapp/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.ktcore-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.ktcore-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.ktcore-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.ktcore-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcIncomingMessage.ktbridge/src/server.tsbridge/src/process-manager.tsbridge/src/session-indexer.ts