Implement client-server architecture for Android app

ober

33a046f55e0e673484b37bba72ea2c69d73ed8d2

diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7df7f3d..3058c07 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="dev.jerboa.jcode"
-    android:versionCode="1"
-    android:versionName="0.1.0"
+    android:versionCode="2"
+    android:versionName="0.2.0"
     android:installLocation="auto">
 
     <uses-permission android:name="android.permission.INTERNET" />
@@ -13,7 +13,6 @@
         android:icon="@drawable/ic_launcher"
         android:theme="@android:style/Theme.Material.Light.DarkActionBar"
         android:networkSecurityConfig="@xml/network_security_config"
-        android:extractNativeLibs="true"
         android:debuggable="true"
         android:allowBackup="false">
 
diff --git a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
new file mode 100644
index 0000000..caa5ab3
--- /dev/null
+++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
@@ -0,0 +1,289 @@
+package dev.jerboa.jcode
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.BufferedWriter
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.net.Socket
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * TCP socket client for connecting to `jcode serve --port PORT` in Termux.
+ *
+ * Replaces JcodeProcess (which spawned an embedded binary). The jcode server
+ * runs in Termux with full environment access; this client connects over
+ * localhost and communicates via the same JSONL protocol.
+ *
+ * Auth handshake: on connect, sends {"type":"auth","token":"<token>"} and
+ * waits for {"type":"auth_ok"}. On failure, reports to listener and closes.
+ */
+class JcodeClient(private val context: Context) {
+
+    interface Listener {
+        fun onConnected()
+        fun onReady(sessionId: String, title: String)
+        fun onToken(text: String)
+        fun onToolStart(name: String, argsJson: String)
+        fun onToolEnd(name: String)
+        fun onTurnEnd(sessionId: String)
+        fun onError(message: String)
+        fun onDisconnected(reason: String)
+        fun onAuthFailed(message: String)
+    }
+
+    private val main = Handler(Looper.getMainLooper())
+    private val outbound = LinkedBlockingQueue<String>()
+    private val connected = AtomicBoolean(false)
+    private val shouldReconnect = AtomicBoolean(false)
+
+    private var socket: Socket? = null
+    private var writerThread: Thread? = null
+    private var readerThread: Thread? = null
+    private var reconnectThread: Thread? = null
+
+    var listener: Listener? = null
+
+    fun isConnected(): Boolean = connected.get()
+
+    /**
+     * Read connection settings from SharedPreferences.
+     */
+    private fun prefs(): SharedPreferences =
+        context.getSharedPreferences("jcode_settings", Context.MODE_PRIVATE)
+
+    fun getHost(): String = prefs().getString("host", "127.0.0.1") ?: "127.0.0.1"
+    fun getPort(): Int = prefs().getInt("port", 8321)
+    fun getToken(): String = prefs().getString("token", "") ?: ""
+
+    /**
+     * Connect to the jcode server. Runs the connection attempt on a background
+     * thread. Calls listener on the main thread.
+     */
+    fun connect() {
+        disconnect()
+        shouldReconnect.set(true)
+
+        val host = getHost()
+        val port = getPort()
+        val token = getToken()
+
+        if (token.isEmpty()) {
+            main.post { listener?.onAuthFailed("No auth token configured. Open Settings and paste the token from Termux.") }
+            return
+        }
+
+        Thread {
+            try {
+                Log.i(TAG, "connecting to $host:$port")
+                val s = Socket(host, port)
+                socket = s
+                connected.set(true)
+
+                val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
+                val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
+
+                // Send auth handshake
+                val authMsg = JSONObject()
+                    .put("type", "auth")
+                    .put("token", token)
+                writer.write(authMsg.toString() + "\n")
+                writer.flush()
+
+                // Wait for auth response
+                val responseLine = reader.readLine()
+                if (responseLine == null) {
+                    connected.set(false)
+                    main.post { listener?.onDisconnected("Server closed connection during auth") }
+                    s.close()
+                    return@Thread
+                }
+
+                val response = JSONObject(responseLine)
+                val type = response.optString("type", "")
+
+                if (type == "error") {
+                    connected.set(false)
+                    val msg = response.optString("message", "auth failed")
+                    main.post { listener?.onAuthFailed(msg) }
+                    s.close()
+                    return@Thread
+                }
+
+                if (type != "auth_ok") {
+                    connected.set(false)
+                    main.post { listener?.onAuthFailed("Unexpected response: $type") }
+                    s.close()
+                    return@Thread
+                }
+
+                // Auth succeeded
+                Log.i(TAG, "authenticated to $host:$port")
+                main.post { listener?.onConnected() }
+
+                // Start writer thread
+                writerThread = Thread { writerLoop(writer) }.apply {
+                    name = "jcode-writer"
+                    isDaemon = true
+                    start()
+                }
+
+                // Reader loop runs on this thread
+                readerLoop(reader)
+
+            } catch (e: Exception) {
+                Log.w(TAG, "connect failed", e)
+                connected.set(false)
+                main.post { listener?.onDisconnected("Connection failed: ${e.message}") }
+                scheduleReconnect()
+            }
+        }.apply {
+            name = "jcode-connect"
+            isDaemon = true
+            start()
+        }
+    }
+
+    /**
+     * Disconnect from the server. Safe to call multiple times.
+     */
+    fun disconnect() {
+        shouldReconnect.set(false)
+        connected.set(false)
+        outbound.clear()
+        reconnectThread?.interrupt()
+        reconnectThread = null
+        socket?.let {
+            try { it.close() } catch (_: Exception) {}
+        }
+        socket = null
+    }
+
+    /**
+     * Send a JSON event to the server. Non-blocking.
+     */
+    fun send(event: JSONObject) {
+        if (!connected.get()) {
+            Log.w(TAG, "send() while not connected, dropped: $event")
+            return
+        }
+        outbound.put(event.toString() + "\n")
+    }
+
+    fun sendUserMessage(text: String, mode: String) {
+        val ev = JSONObject()
+            .put("type", "user")
+            .put("text", text)
+            .put("mode", mode)
+        send(ev)
+    }
+
+    fun sendNewSession(title: String? = null) {
+        val ev = JSONObject().put("type", "new_session")
+        if (title != null) ev.put("title", title)
+        send(ev)
+    }
+
+    fun sendPing() {
+        send(JSONObject().put("type", "ping"))
+    }
+
+    // ── internal ────────────────────────────────────────────────────────
+
+    private fun writerLoop(writer: BufferedWriter) {
+        try {
+            while (connected.get()) {
+                val line = outbound.take()
+                writer.write(line)
+                writer.flush()
+            }
+        } catch (_: InterruptedException) {
+            // expected on shutdown
+        } catch (e: Exception) {
+            Log.w(TAG, "writer failed", e)
+        }
+    }
+
+    private fun readerLoop(reader: BufferedReader) {
+        try {
+            while (connected.get()) {
+                val line = reader.readLine() ?: break
+                if (line.isEmpty()) continue
+                try {
+                    val obj = JSONObject(line)
+                    dispatch(obj)
+                } catch (e: Exception) {
+                    Log.w(TAG, "bad JSON line: $line", e)
+                }
+            }
+        } catch (e: Exception) {
+            Log.w(TAG, "reader failed", e)
+        } finally {
+            if (connected.getAndSet(false)) {
+                main.post { listener?.onDisconnected("Server disconnected") }
+                scheduleReconnect()
+            }
+        }
+    }
+
+    private fun dispatch(obj: JSONObject) {
+        val type = obj.optString("type", "")
+        main.post {
+            val l = listener ?: return@post
+            when (type) {
+                "ready" -> l.onReady(
+                    obj.optString("session_id", ""),
+                    obj.optString("session_title", "")
+                )
+                "token" -> l.onToken(obj.optString("text", ""))
+                "tool_start" -> l.onToolStart(
+                    obj.optString("name", "?"),
+                    obj.optJSONObject("args")?.toString() ?: "{}"
+                )
+                "tool_end" -> l.onToolEnd(obj.optString("name", "?"))
+                "turn_end" -> l.onTurnEnd(obj.optString("session_id", ""))
+                "error" -> l.onError(obj.optString("message", "unknown error"))
+                "pong" -> {} // silent heartbeat ack
+                else -> Log.d(TAG, "unhandled event type: $type")
+            }
+        }
+    }
+
+    /**
+     * Reconnect with exponential backoff: 1s, 2s, 4s, max 10s.
+     */
+    private fun scheduleReconnect() {
+        if (!shouldReconnect.get()) return
+
+        reconnectThread = Thread {
+            var delay = 1000L
+            while (shouldReconnect.get() && !connected.get()) {
+                try {
+                    Log.i(TAG, "reconnecting in ${delay}ms")
+                    Thread.sleep(delay)
+                    if (shouldReconnect.get() && !connected.get()) {
+                        connect()
+                        return@Thread
+                    }
+                } catch (_: InterruptedException) {
+                    return@Thread
+                }
+                delay = minOf(delay * 2, 10_000L)
+            }
+        }.apply {
+            name = "jcode-reconnect"
+            isDaemon = true
+            start()
+        }
+    }
+
+    companion object {
+        private const val TAG = "JcodeClient"
+    }
+}
diff --git a/android/app/src/main/java/dev/jerboa/jcode/JcodeProcess.kt b/android/app/src/main/java/dev/jerboa/jcode/JcodeProcess.kt
deleted file mode 100644
index e36b58e..0000000
--- a/android/app/src/main/java/dev/jerboa/jcode/JcodeProcess.kt
+++ /dev/null
@@ -1,280 +0,0 @@
-package dev.jerboa.jcode
-
-import android.content.Context
-import android.os.Handler
-import android.os.Looper
-import android.util.Log
-import org.json.JSONObject
-import java.io.BufferedReader
-import java.io.BufferedWriter
-import java.io.File
-import java.io.InputStreamReader
-import java.io.OutputStreamWriter
-import java.util.concurrent.LinkedBlockingQueue
-import java.util.concurrent.atomic.AtomicBoolean
-
-/**
- * Manages a long-running `jcode serve` subprocess.
- *
- * The jcode binary ships inside the APK as lib/arm64-v8a/libjcode.so and is
- * extracted to nativeLibraryDir at install time (extractNativeLibs=true in
- * manifest). The rename is required because Android >=29 blocks exec from the
- * app's regular data dir but allows exec from the native library dir, and
- * anything in that dir must be named lib*.so for PackageManager to extract it.
- *
- * Protocol is newline-delimited JSON in both directions; see serve.ss in the
- * Scheme sources for the event schema.
- */
-class JcodeProcess(private val context: Context) {
-
-    interface Listener {
-        fun onReady(sessionId: String, title: String)
-        fun onToken(text: String)
-        fun onToolStart(name: String, argsJson: String)
-        fun onToolEnd(name: String)
-        fun onTurnEnd(sessionId: String)
-        fun onError(message: String)
-        fun onProcessDied(exitCode: Int, stderrTail: String)
-    }
-
-    private val main = Handler(Looper.getMainLooper())
-    private val outbound = LinkedBlockingQueue<String>()
-    private val alive = AtomicBoolean(false)
-    private val stderrTail = StringBuilder()
-
-    private var proc: Process? = null
-    private var writerThread: Thread? = null
-    private var readerThread: Thread? = null
-    private var stderrThread: Thread? = null
-
-    var listener: Listener? = null
-
-    fun isRunning(): Boolean = alive.get()
-
-    /**
-     * Spawn the subprocess. Throws if the binary is missing or exec fails.
-     * Safe to call multiple times; stops any previous process first.
-     */
-    fun start() {
-        stop()
-
-        val nativeDir = context.applicationInfo.nativeLibraryDir
-        val binary = File(nativeDir, "libjcode.so")
-        if (!binary.exists()) {
-            throw IllegalStateException(
-                "jcode binary not found at ${binary.absolutePath} — " +
-                        "APK may be missing jniLibs or extractNativeLibs is false"
-            )
-        }
-
-        // Extract bundled shared libs from assets/native/ → filesDir/native/.
-        // These are the Termux-originated deps the binary needs (ncursesw, iconv,
-        // sqlite3, z). Android's lib/<abi>/ mechanism only extracts files literally
-        // ending in .so, so suffix-versioned names like libncursesw.so.6 can't live
-        // there — we ship them in assets and copy them out on first launch.
-        val libDir = File(context.filesDir, "native")
-        extractNativeLibs(libDir)
-
-        val filesDir = context.filesDir
-        val pb = ProcessBuilder(binary.absolutePath, "serve")
-            .directory(filesDir)
-            .redirectErrorStream(false)
-
-        val ldPath = "${libDir.absolutePath}:$nativeDir"
-        val env = pb.environment()
-        env["HOME"] = filesDir.absolutePath
-        env["LD_LIBRARY_PATH"] = ldPath
-        env["TMPDIR"] = context.cacheDir.absolutePath
-        env["TERM"] = "dumb"
-        env["NO_COLOR"] = "1"
-
-        Log.i(TAG, "spawning ${binary.absolutePath}")
-        Log.i(TAG, "  cwd=${filesDir.absolutePath}")
-        Log.i(TAG, "  LD_LIBRARY_PATH=$ldPath")
-
-        val p = pb.start()
-        proc = p
-        alive.set(true)
-        stderrTail.clear()
-
-        writerThread = Thread { writerLoop(p) }.apply {
-            name = "jcode-writer"
-            isDaemon = true
-            start()
-        }
-        readerThread = Thread { readerLoop(p) }.apply {
-            name = "jcode-reader"
-            isDaemon = true
-            start()
-        }
-        stderrThread = Thread { stderrLoop(p) }.apply {
-            name = "jcode-stderr"
-            isDaemon = true
-            start()
-        }
-    }
-
-    /**
-     * Kill the subprocess if alive. Safe to call multiple times.
-     */
-    fun stop() {
-        alive.set(false)
-        proc?.let {
-            try {
-                it.destroy()
-            } catch (e: Exception) {
-                Log.w(TAG, "destroy failed", e)
-            }
-        }
-        proc = null
-        outbound.clear()
-    }
-
-    /**
-     * Send an event object to jcode. Non-blocking — enqueues on the writer
-     * thread's queue.
-     */
-    fun send(event: JSONObject) {
-        if (!alive.get()) {
-            Log.w(TAG, "send() while not alive, dropped: $event")
-            return
-        }
-        outbound.put(event.toString() + "\n")
-    }
-
-    fun sendUserMessage(text: String, mode: String) {
-        val ev = JSONObject()
-            .put("type", "user")
-            .put("text", text)
-            .put("mode", mode)
-        send(ev)
-    }
-
-    fun sendNewSession(title: String? = null) {
-        val ev = JSONObject().put("type", "new_session")
-        if (title != null) ev.put("title", title)
-        send(ev)
-    }
-
-    // ── internal ────────────────────────────────────────────────────────
-
-    /**
-     * Copy every file under assets/native/ into [libDir], overwriting if the
-     * source differs in size from the existing destination. Idempotent.
-     */
-    private fun extractNativeLibs(libDir: File) {
-        if (!libDir.exists()) libDir.mkdirs()
-        val am = context.assets
-        val files = am.list("native") ?: emptyArray()
-        for (name in files) {
-            val dest = File(libDir, name)
-            try {
-                am.open("native/$name").use { input ->
-                    val bytes = input.readBytes()
-                    if (dest.exists() && dest.length().toInt() == bytes.size) {
-                        // Good enough signature; skip rewrite.
-                        return@use
-                    }
-                    dest.writeBytes(bytes)
-                    Log.i(TAG, "extracted $name (${bytes.size} bytes)")
-                }
-            } catch (e: Exception) {
-                Log.w(TAG, "failed to extract $name", e)
-            }
-        }
-    }
-
-    private fun writerLoop(p: Process) {
-        val w = BufferedWriter(OutputStreamWriter(p.outputStream, Charsets.UTF_8))
-        try {
-            while (alive.get()) {
-                val line = outbound.take()
-                w.write(line)
-                w.flush()
-            }
-        } catch (e: InterruptedException) {
-            // expected on shutdown
-        } catch (e: Exception) {
-            Log.w(TAG, "writer failed", e)
-        } finally {
-            try { w.close() } catch (_: Exception) {}
-        }
-    }
-
-    private fun readerLoop(p: Process) {
-        val r = BufferedReader(InputStreamReader(p.inputStream, Charsets.UTF_8))
-        try {
-            while (alive.get()) {
-                val line = r.readLine() ?: break
-                if (line.isEmpty()) continue
-                try {
-                    val obj = JSONObject(line)
-                    dispatch(obj)
-                } catch (e: Exception) {
-                    Log.w(TAG, "bad JSON line: $line", e)
-                }
-            }
-        } catch (e: Exception) {
-            Log.w(TAG, "reader failed", e)
-        } finally {
-            try { r.close() } catch (_: Exception) {}
-            handleProcessDeath(p)
-        }
-    }
-
-    private fun stderrLoop(p: Process) {
-        val r = BufferedReader(InputStreamReader(p.errorStream, Charsets.UTF_8))
-        try {
-            while (alive.get()) {
-                val line = r.readLine() ?: break
-                Log.i("jcode-stderr", line)
-                synchronized(stderrTail) {
-                    stderrTail.append(line).append('\n')
-                    // Keep last ~4kb for crash reporting
-                    if (stderrTail.length > 4096) {
-                        stderrTail.delete(0, stderrTail.length - 4096)
-                    }
-                }
-            }
-        } catch (e: Exception) {
-            Log.w(TAG, "stderr reader failed", e)
-        } finally {
-            try { r.close() } catch (_: Exception) {}
-        }
-    }
-
-    private fun handleProcessDeath(p: Process) {
-        if (!alive.getAndSet(false)) return
-        val exit = try { p.waitFor() } catch (_: Exception) { -1 }
-        val tail = synchronized(stderrTail) { stderrTail.toString() }
-        Log.w(TAG, "jcode exited code=$exit")
-        main.post { listener?.onProcessDied(exit, tail) }
-    }
-
-    private fun dispatch(obj: JSONObject) {
-        val type = obj.optString("type", "")
-        main.post {
-            val l = listener ?: return@post
-            when (type) {
-                "ready" -> l.onReady(
-                    obj.optString("session_id", ""),
-                    obj.optString("session_title", "")
-                )
-                "token" -> l.onToken(obj.optString("text", ""))
-                "tool_start" -> l.onToolStart(
-                    obj.optString("name", "?"),
-                    obj.optJSONObject("args")?.toString() ?: "{}"
-                )
-                "tool_end" -> l.onToolEnd(obj.optString("name", "?"))
-                "turn_end" -> l.onTurnEnd(obj.optString("session_id", ""))
-                "error" -> l.onError(obj.optString("message", "unknown error"))
-                "pong" -> {} // silent heartbeat ack
-                else -> Log.d(TAG, "unhandled event type: $type")
-            }
-        }
-    }
-
-    companion object {
-        private const val TAG = "JcodeProcess"
-    }
-}
diff --git a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
index 16e954c..de6aaf9 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
@@ -1,6 +1,7 @@
 package dev.jerboa.jcode
 
 import android.app.Activity
+import android.content.Context
 import android.content.Intent
 import android.os.Bundle
 import android.text.Editable
@@ -15,17 +16,13 @@ import android.widget.ScrollView
 import android.widget.Switch
 import android.widget.TextView
 import android.widget.Toast
-import java.io.File
 
 /**
- * Chat UI for jcode. Spawns a `jcode serve` subprocess, wires its JSONL events
- * to dynamically-appended bubble views in a ScrollView, and sends user input
- * with the current mode (plan/build).
- *
- * Chat container is a plain LinearLayout inside a ScrollView — no RecyclerView
- * so we avoid the androidx dependency (which is painful without gradle).
+ * Chat UI for jcode. Connects to `jcode serve --port PORT` running in Termux
+ * over a localhost TCP socket, wires JSONL events to dynamically-appended
+ * bubble views in a ScrollView.
  */
-class MainActivity : Activity(), JcodeProcess.Listener {
+class MainActivity : Activity(), JcodeClient.Listener {
 
     private lateinit var titleText: TextView
     private lateinit var modeLabel: TextView
@@ -37,8 +34,11 @@ class MainActivity : Activity(), JcodeProcess.Listener {
     private lateinit var statusLine: TextView
     private lateinit var inputText: EditText
     private lateinit var btnSend: Button
+    private lateinit var connectionBanner: LinearLayout
+    private lateinit var bannerText: TextView
+    private lateinit var btnReconnect: Button
 
-    private lateinit var jcode: JcodeProcess
+    private lateinit var client: JcodeClient
 
     /** TextView of the in-progress assistant bubble, or null when idle. */
     private var currentAssistantText: TextView? = null
@@ -62,6 +62,9 @@ class MainActivity : Activity(), JcodeProcess.Listener {
         statusLine = findViewById(R.id.status_line)
         inputText = findViewById(R.id.input_text)
         btnSend = findViewById(R.id.btn_send)
+        connectionBanner = findViewById(R.id.connection_banner)
+        bannerText = findViewById(R.id.banner_text)
+        btnReconnect = findViewById(R.id.btn_reconnect)
 
         modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) }
         updateModeLabel(modeSwitch.isChecked)
@@ -71,11 +74,11 @@ class MainActivity : Activity(), JcodeProcess.Listener {
         }
 
         btnNewSession.setOnClickListener {
-            if (!jcode.isRunning()) return@setOnClickListener
+            if (!client.isConnected()) return@setOnClickListener
             chatContainer.removeAllViews()
             currentAssistantText = null
             currentAssistantBuf.setLength(0)
-            jcode.sendNewSession()
+            client.sendNewSession()
             setStatus(getString(R.string.status_ready))
         }
 
@@ -87,35 +90,38 @@ class MainActivity : Activity(), JcodeProcess.Listener {
             } else false
         }
 
-        jcode = JcodeProcess(this)
-        jcode.listener = this
+        btnReconnect.setOnClickListener {
+            client.connect()
+            showBanner("Connecting...")
+        }
+
+        client = JcodeClient(this)
+        client.listener = this
+
+        // Show disconnected state initially
+        showBanner("Not connected \u2014 start jcode serve in Termux")
+        setInputEnabled(false)
     }
 
     override fun onStart() {
         super.onStart()
 
-        // If no keys yet, push the user to settings before we try to spawn.
-        val keys = File(filesDir, "jcode.json")
-        if (!keys.exists()) {
-            Toast.makeText(this, R.string.no_keys, Toast.LENGTH_LONG).show()
-            startActivity(Intent(this, SettingsActivity::class.java))
+        val token = client.getToken()
+        if (token.isEmpty()) {
+            showBanner("No token configured \u2014 open Settings")
+            Toast.makeText(this, "Configure server connection in Settings", Toast.LENGTH_LONG).show()
             return
         }
 
-        if (!jcode.isRunning()) {
-            try {
-                jcode.start()
-                setStatus("Starting…")
-            } catch (e: Exception) {
-                Log.e(TAG, "spawn failed", e)
-                appendError("Failed to spawn jcode: ${e.message}")
-            }
+        if (!client.isConnected()) {
+            showBanner("Connecting...")
+            client.connect()
         }
     }
 
     override fun onDestroy() {
         super.onDestroy()
-        jcode.stop()
+        client.disconnect()
     }
 
     // ── UI helpers ──────────────────────────────────────────────────────
@@ -139,6 +145,20 @@ class MainActivity : Activity(), JcodeProcess.Listener {
         statusLine.visibility = View.GONE
     }
 
+    private fun showBanner(text: String) {
+        bannerText.text = text
+        connectionBanner.visibility = View.VISIBLE
+    }
+
+    private fun hideBanner() {
+        connectionBanner.visibility = View.GONE
+    }
+
+    private fun setInputEnabled(enabled: Boolean) {
+        inputText.isEnabled = enabled
+        btnSend.isEnabled = enabled && !sending
+    }
+
     private fun scrollToBottom() {
         chatScroll.post { chatScroll.fullScroll(View.FOCUS_DOWN) }
     }
@@ -177,7 +197,7 @@ class MainActivity : Activity(), JcodeProcess.Listener {
 
     private fun appendTool(name: String, argsJson: String) {
         val view = layoutInflater.inflate(R.layout.item_chat_tool, chatContainer, false)
-        view.findViewById<TextView>(R.id.tool_header).text = "⚙ $name"
+        view.findViewById<TextView>(R.id.tool_header).text = "\u2699 $name"
         view.findViewById<TextView>(R.id.tool_args).text = argsJson
         chatContainer.addView(view)
         scrollToBottom()
@@ -196,8 +216,8 @@ class MainActivity : Activity(), JcodeProcess.Listener {
         if (sending) return
         val text = inputText.text.toString().trim()
         if (text.isEmpty()) return
-        if (!jcode.isRunning()) {
-            appendError("jcode is not running — try restarting the app")
+        if (!client.isConnected()) {
+            appendError("Not connected \u2014 is jcode serve running in Termux?")
             return
         }
 
@@ -206,15 +226,21 @@ class MainActivity : Activity(), JcodeProcess.Listener {
         appendUser(text)
         beginAssistant()
         setStatus(getString(R.string.status_thinking))
-        jcode.sendUserMessage(text, currentMode())
+        client.sendUserMessage(text, currentMode())
         inputText.text = Editable.Factory.getInstance().newEditable("")
     }
 
-    // ── JcodeProcess.Listener (main thread) ─────────────────────────────
+    // ── JcodeClient.Listener (main thread) ──────────────────────────────
+
+    override fun onConnected() {
+        hideBanner()
+        setInputEnabled(true)
+        setStatus("Connected")
+    }
 
     override fun onReady(sessionId: String, title: String) {
         if (title.isNotEmpty()) titleText.text = title
-        setStatus("Ready · session ${sessionId.take(8)}")
+        setStatus("Ready \u00b7 session ${sessionId.take(8)}")
     }
 
     override fun onToken(text: String) {
@@ -245,13 +271,18 @@ class MainActivity : Activity(), JcodeProcess.Listener {
         btnSend.isEnabled = true
     }
 
-    override fun onProcessDied(exitCode: Int, stderrTail: String) {
-        val tailShort = if (stderrTail.length > 800) stderrTail.takeLast(800) else stderrTail
-        appendError("jcode died (exit=$exitCode)\n$tailShort")
+    override fun onDisconnected(reason: String) {
+        showBanner("Disconnected \u2014 $reason")
+        setInputEnabled(false)
         finalizeAssistant()
         hideStatus()
         sending = false
-        btnSend.isEnabled = true
+    }
+
+    override fun onAuthFailed(message: String) {
+        showBanner("Auth failed \u2014 $message")
+        setInputEnabled(false)
+        appendError("Authentication failed: $message")
     }
 
     companion object {
diff --git a/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt b/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt
index 4218124..ee973d6 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt
@@ -1,108 +1,95 @@
 package dev.jerboa.jcode
 
 import android.app.Activity
-import android.content.Intent
-import android.net.Uri
+import android.content.Context
+import android.content.SharedPreferences
 import android.os.Bundle
-import android.util.Log
 import android.widget.Button
+import android.widget.EditText
 import android.widget.TextView
 import android.widget.Toast
-import org.json.JSONObject
-import java.io.File
 
 /**
- * Settings: pick a jcode.json file from storage and copy it into
- * filesDir/jcode.json. Also shows the working directory (filesDir).
+ * Settings: configure the jcode server connection (host, port, auth token).
+ * Keys are stored in SharedPreferences "jcode_settings".
  */
 class SettingsActivity : Activity() {
 
-    private lateinit var keysStatus: TextView
-    private lateinit var workdirText: TextView
-    private lateinit var btnPickKeys: Button
+    private lateinit var hostInput: EditText
+    private lateinit var portInput: EditText
+    private lateinit var tokenInput: EditText
+    private lateinit var statusText: TextView
+    private lateinit var btnSave: Button
+
+    private fun prefs(): SharedPreferences =
+        getSharedPreferences("jcode_settings", Context.MODE_PRIVATE)
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_settings)
 
-        keysStatus = findViewById(R.id.keys_status)
-        workdirText = findViewById(R.id.workdir_text)
-        btnPickKeys = findViewById(R.id.btn_pick_keys)
-
-        workdirText.text = filesDir.absolutePath
+        hostInput = findViewById(R.id.input_host)
+        portInput = findViewById(R.id.input_port)
+        tokenInput = findViewById(R.id.input_token)
+        statusText = findViewById(R.id.connection_status)
+        btnSave = findViewById(R.id.btn_save)
 
-        btnPickKeys.setOnClickListener {
-            val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
-                addCategory(Intent.CATEGORY_OPENABLE)
-                type = "*/*"
-            }
-            startActivityForResult(intent, REQ_PICK_KEYS)
-        }
+        loadSettings()
 
-        refreshKeysStatus()
+        btnSave.setOnClickListener { saveSettings() }
     }
 
     override fun onResume() {
         super.onResume()
-        refreshKeysStatus()
+        loadSettings()
+    }
+
+    private fun loadSettings() {
+        val p = prefs()
+        hostInput.setText(p.getString("host", "127.0.0.1"))
+        portInput.setText(p.getInt("port", 8321).toString())
+        tokenInput.setText(p.getString("token", ""))
+
+        val token = p.getString("token", "") ?: ""
+        statusText.text = if (token.isEmpty()) {
+            "Not configured \u2014 paste the token from: jcode serve --port 8321"
+        } else {
+            "Token configured (${token.length} chars). Save and return to connect."
+        }
     }
 
-    private fun refreshKeysStatus() {
-        val f = File(filesDir, "jcode.json")
-        if (!f.exists()) {
-            keysStatus.text = getString(R.string.no_keys)
+    private fun saveSettings() {
+        val host = hostInput.text.toString().trim()
+        val portStr = portInput.text.toString().trim()
+        val token = tokenInput.text.toString().trim()
+
+        if (host.isEmpty()) {
+            Toast.makeText(this, "Host cannot be empty", Toast.LENGTH_SHORT).show()
             return
         }
-        // Parse and show which providers are configured, without leaking key material.
-        try {
-            val text = f.readText(Charsets.UTF_8)
-            val obj = JSONObject(text)
-            val providers = mutableListOf<String>()
-            val iter = obj.keys()
-            while (iter.hasNext()) {
-                val k = iter.next()
-                val v = obj.opt(k)
-                if (v is String && v.isNotEmpty()) providers.add(k)
-            }
-            keysStatus.text = if (providers.isEmpty()) {
-                "jcode.json present but has no keys"
-            } else {
-                "API keys loaded: ${providers.joinToString(", ")}"
-            }
-        } catch (e: Exception) {
-            keysStatus.text = "jcode.json present but not valid JSON: ${e.message}"
+
+        val port = portStr.toIntOrNull()
+        if (port == null || port < 1 || port > 65535) {
+            Toast.makeText(this, "Port must be 1\u201365535", Toast.LENGTH_SHORT).show()
+            return
         }
-    }
 
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        super.onActivityResult(requestCode, resultCode, data)
-        if (requestCode != REQ_PICK_KEYS || resultCode != RESULT_OK) return
-        val uri: Uri = data?.data ?: return
-
-        try {
-            contentResolver.openInputStream(uri).use { input ->
-                if (input == null) {
-                    Toast.makeText(this, "Could not open file", Toast.LENGTH_SHORT).show()
-                    return
-                }
-                val bytes = input.readBytes()
-                // Sanity-check: must be valid JSON.
-                val text = String(bytes, Charsets.UTF_8)
-                JSONObject(text)  // throws on bad JSON
-
-                val dest = File(filesDir, "jcode.json")
-                dest.writeBytes(bytes)
-                Toast.makeText(this, R.string.keys_loaded, Toast.LENGTH_SHORT).show()
-                refreshKeysStatus()
-            }
-        } catch (e: Exception) {
-            Log.e(TAG, "copy failed", e)
-            Toast.makeText(this, "Import failed: ${e.message}", Toast.LENGTH_LONG).show()
+        if (token.isEmpty()) {
+            Toast.makeText(this, "Token cannot be empty", Toast.LENGTH_SHORT).show()
+            return
         }
+
+        prefs().edit()
+            .putString("host", host)
+            .putInt("port", port)
+            .putString("token", token)
+            .apply()
+
+        Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
+        statusText.text = "Saved. Return to main screen to connect."
     }
 
     companion object {
         private const val TAG = "jcode.Settings"
-        private const val REQ_PICK_KEYS = 1001
     }
 }
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index b5e9634..cde6d6e 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -59,6 +59,40 @@
             android:src="@android:drawable/ic_menu_preferences" />
     </LinearLayout>
 
+    <!-- Connection banner (shown when disconnected) -->
+    <LinearLayout
+        android:id="@+id/connection_banner"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/error_bg"
+        android:gravity="center_vertical"
+        android:orientation="horizontal"
+        android:paddingStart="16dp"
+        android:paddingEnd="8dp"
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp"
+        android:visibility="gone">
+
+        <TextView
+            android:id="@+id/banner_text"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="Not connected"