diff --git a/.gitignore b/.gitignore
index 56aa4d7..c514483 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,6 @@ docs/
dev/deploy
dev/deploy.*
target/
+.cargo/
AGENTS.md
CLAUDE.md
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..4cc5607
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,9 @@
+# cross-compiled humd binary — built by android/scripts/build-humd.sh
+app/src/main/assets/humd
+
+# gradle / android build outputs
+app/build/
+build/
+.cargo/
+*.apk
+*.log
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..fd70fc1
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,82 @@
+---
+title: "android-humd"
+description: "humd for Android — the native Rust daemon, cross-compiled and hosted by a foreground service, with WiFi Direct as the radio underlay"
+---
+
+# android-humd
+
+> _humd for Android — the actual daemon, not a thin client._
+
+This is **humd itself compiled for Android**: the full Rust workspace
+(`humd` + `ensemble` + `config` + `thrum-core` + `hum-paths` + …)
+cross-compiled to an `aarch64-linux-android` PIE ELF and hosted by a
+native foreground service. Rust was chosen because it runs everywhere —
+so the portability problem is *build* engineering, not a rewrite: the
+daemon boots unchanged, hum-paths resolves everything from XDG env
+vars, and the host points those at app-private storage.
+
+No Termux, no root, no JNI. The binary ships as an asset and is
+`exec`'d by the service.
+
+## Why this exists
+
+The ensemble is the mesh of cooperating humds. To bring a phone into
+it without Termux, the phone must *run* humd, not just dial one. This
+module is that port. Two layers stack:
+
+- **Overlay — the ensemble mesh**: iroh (QUIC + Noise + relay
+ hole-punching) reaches the machine's humd over the internet / relay,
+ no WiFi Direct needed. This is the "thrum is the end of TCP" path.
+- **Underlay — WiFi Direct**: `WifiP2pManager` gives phones an ad-hoc
+ L2 link (p2p0) without a router or internet — the phone↔phone
+ meetup case. The native daemon's iroh runs on p2p0 when a group forms.
+
+## Layout
+
+```
+android/
+ scripts/build-humd.sh NDK cross-compile of the workspace humd → assets
+ app/ Android app (foreground service host)
+ src/main/java/hum/daemon/
+ HumdService.kt spawns + supervises the native humd, writes config
+ WifiDirectManager.kt radio underlay (WifiP2pManager discovery + groups)
+```
+
+## Build
+
+Prereqs: `rustup target add aarch64-linux-android`, an Android NDK
+(`brew install --cask android-commandlinetools`, then
+`sdkmanager "ndk;27d"`, or set `ANDROID_NDK_HOME`).
+
+```sh
+# cross-compile humd and bundle it into the app
+./android/scripts/build-humd.sh
+
+# then assemble the APK from android/
+(cd android && ./gradlew :app:assembleDebug)
+```
+
+## Runtime
+
+`HumdService` (a foreground `dataSync` service) extracts the bundled
+binary, writes a minimal `hum.json` (all sections default) plus an
+optional `peers.json` from `-Dhum.peerHint=humd_id,iroh:nodeid` so the
+phone daemon dials the machine on boot, points XDG_* at app-private
+storage, and `exec`s the daemon — supervising it and logging to
+`filesDir/humd.log`.
+
+The phone then participates in the ensemble exactly as any humd: signed
+hello, peer registry, kad, gossip — and routes prompts to whatever
+worker bees register (e.g. the machine's `ollama-worker`).
+
+## Propensity
+
+| statefulness | richness | wire shape | hides |
+|---|---|---|---|
+| convention-stateful | lean | ensemble (iroh QUIC) + thrum (Unix socket) | tools, drone, breath |
+
+## See also
+
+- `hives/wifi-p2p` — the radio-underlay prototype (WiFi Direct forager bee)
+- `ensemble/` — the mesh layer this daemon runs
+- `hum-paths/` — XDG resolution that makes the port a build problem, not a code problem
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..f862726
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,39 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "hum.daemon"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "hum.daemon"
+ minSdk = 29
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.32.0"
+ }
+
+ // The native humd is a PIE ELF cross-compiled from the Rust workspace
+ // (see android/scripts/build-humd.sh) and bundled as a plain asset.
+ // The foreground service extracts it to filesDir and execs it — no
+ // Termux, no root, no JNI. Keep it out of source control.
+ sourceSets["main"].assets.srcDirs("src/main/assets")
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-service:2.8.4")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..12e61a1
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/hum/daemon/HumdService.kt b/android/app/src/main/java/hum/daemon/HumdService.kt
new file mode 100644
index 0000000..01968e8
--- /dev/null
+++ b/android/app/src/main/java/hum/daemon/HumdService.kt
@@ -0,0 +1,208 @@
+package hum.daemon
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Environment
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Hosts the native hum daemon for Android.
+ *
+ * "humd for Android" is the actual Rust `humd` from the workspace,
+ * cross-compiled to a PIE ELF (see android/scripts/build-humd.sh) and
+ * bundled as an asset. This service is the host: it extracts the binary,
+ * points every XDG path at app-private storage, writes a minimal
+ * hum.json (+ optional peers.json with the companion machine's iroh
+ * hint), and spawns the daemon as a supervised child process. No
+ * Termux, no root, no JNI — the daemon is a plain exec.
+ *
+ * humd's boot is entirely XDG-driven (hum-paths), so the Android port
+ * needs zero Rust changes. The ensemble mesh (iroh QUIC + relay) is
+ * the overlay that reaches the machine's humd; WifiDirectManager is
+ * the radio underlay for ad-hoc peer discovery.
+ */
+class HumdService : Service() {
+ companion object {
+ const val TAG = "humd.android"
+ const val CHANNEL = "humd"
+ private const val BIN_ASSET = "humd"
+ }
+
+ private var proc: Process? = null
+ private val alive = AtomicBoolean(true)
+ private var wifi: WifiDirectManager? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ nm.createNotificationChannel(
+ NotificationChannel(CHANNEL, "humd", NotificationManager.IMPORTANCE_LOW)
+ )
+ startForegroundCompat()
+ ensureBinary()
+ writeConfig()
+ startHumd()
+ // Radio underlay for ad-hoc phone↔phone discovery; the ensemble
+ // overlay (iroh relay) needs no WiFi Direct to reach the machine.
+ wifi = WifiDirectManager(this).also {
+ it.onGroupFormed = { isGO, ip ->
+ Log.i(TAG, "p2p link up: go=$isGO ip=$ip")
+ }
+ if (it.start()) Log.i(TAG, "wifi-direct underlay started")
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // Restart if the OS kills us; the daemon process is supervised by
+ // the service and does not die with it.
+ startForegroundCompat()
+ if (proc == null || !proc!!.isAlive) startHumd()
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ alive.set(false)
+ wifi?.stop()
+ proc?.destroy()
+ proc?.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS)
+ super.onDestroy()
+ }
+
+ // ── foreground surface ─────────────────────────────────────────────────
+ private fun startForegroundCompat() {
+ val intent = Intent(this, HumdService::class.java)
+ val pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+ val n = NotificationCompat.Builder(this, CHANNEL)
+ .setContentTitle(getString(R.string.notif_title))
+ .setContentText(getString(R.string.notif_text))
+ .setSmallIcon(android.R.drawable.stat_sys_data_paused)
+ .setContentIntent(pi)
+ .setOngoing(true)
+ .build()
+ if (android.os.Build.VERSION.SDK_INT >= 34) {
+ startForeground(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ startForeground(1, n)
+ }
+ }
+
+ // ── binary ─────────────────────────────────────────────────────────────
+ private fun ensureBinary() {
+ val files = filesDir
+ val dest = File(files, BIN_ASSET)
+ // Idempotent: re-extract only when the bundled asset is bigger
+ // than what we already placed (i.e. a fresh build shipped a new
+ // binary). Otherwise reuse the installed copy.
+ val bundled = assets.open(BIN_ASSET).use { it.available() }
+ if (dest.exists() && dest.length() >= bundled && dest.length() > 0) {
+ dest.setExecutable(true, true)
+ Log.i(TAG, "humd binary already bundled (${dest.length()} bytes)")
+ return
+ }
+ assets.open(BIN_ASSET).use { input ->
+ FileOutputStream(dest).use { output -> input.copyTo(output) }
+ }
+ dest.setExecutable(true, true)
+ Log.i(TAG, "extracted humd binary -> ${dest.absolutePath} (${dest.length()} bytes)")
+ }
+
+ // ── config ─────────────────────────────────────────────────────────────
+ /** Write a minimal valid hum.json (all sections default) plus, if a
+ * companion machine's iroh hint is provided, peers.json so the phone
+ * daemon dials it on boot. */
+ private fun writeConfig() {
+ val cfg = File(configDir(), "hum").apply { mkdirs() }
+ val humJson = File(cfg, "hum.json")
+ if (!humJson.exists()) {
+ humJson.writeText(
+ """
+ {
+ "humd": { "permissionDuskMs": 60000, "driftRetentionDays": 7 },
+ "fs": { "roots": [ { "path": "~/code", "mode": "rw" } ], "denied": [] },
+ "nest": { "maxActiveCells": 1, "cellIdlePruneThresholdMs": 300000, "default": "" }
+ }
+ """.trimIndent()
+ )
+ }
+
+ // Optional bootstrap: the companion machine's humd id + iroh hint.
+ val peerHint = System.getProperty("hum.peerHint") // "humd_id,iroh:nodeid"
+ if (peerHint != null) {
+ val (id, hint) = peerHint.split(",", limit = 2)
+ val peers = File(cfg, "peers.json")
+ if (!peers.exists()) {
+ peers.writeText(
+ """{"peers":[{"humd_id":"$id","hints":["$hint"]}]}"""
+ )
+ }
+ }
+ }
+
+ // ── daemon ─────────────────────────────────────────────────────────────
+ private fun startHumd() {
+ val files = filesDir
+ val bin = File(files, BIN_ASSET)
+ if (!bin.exists()) {
+ Log.e(TAG, "humd binary missing — run android/scripts/build-humd.sh")
+ return
+ }
+
+ val pb = ProcessBuilder(bin.absolutePath)
+ pb.directory(files)
+ pb.redirectErrorStream(true)
+ pb.redirectOutput(ProcessBuilder.Redirect.appendTo(File(files, "humd.log")))
+
+ // hum-paths resolves everything from XDG env vars; pin them to
+ // app-private storage so the daemon's key/socket/config stay inside.
+ val env = pb.environment()
+ env["HOME"] = files.absolutePath
+ env["XDG_STATE_HOME"] = File(files, "state").absolutePath
+ env["XDG_CONFIG_HOME"] = File(files, "config").absolutePath
+ env["XDG_DATA_HOME"] = File(files, "data").absolutePath
+ env["XDG_CACHE_HOME"] = File(files, "cache").absolutePath
+ env["XDG_RUNTIME_DIR"] = File(files, "runtime").absolutePath
+ env["HUM_LOG_LEVEL"] = "info"
+
+ try {
+ proc = pb.start()
+ Log.i(TAG, "humd spawned pid=${proc!!.pid()}")
+ } catch (e: Exception) {
+ Log.e(TAG, "spawn failed: ${e.message}")
+ return
+ }
+
+ // Supervise: if the daemon dies unexpectedly, log and respawn
+ // (bounded) unless the service itself is tearing down.
+ Thread {
+ while (alive.get()) {
+ try {
+ proc!!.waitFor()
+ if (alive.get()) {
+ Log.w(TAG, "humd exited rc=${proc!!.exitValue()}; respawning")
+ Thread.sleep(2000)
+ startHumd()
+ }
+ break
+ } catch (e: InterruptedException) {
+ break
+ }
+ }
+ }.apply { isDaemon = true; start() }
+ }
+
+ // ── path helpers ───────────────────────────────────────────────────────
+ private fun configDir() = File(filesDir, "config")
+}
diff --git a/android/app/src/main/java/hum/daemon/WifiDirectManager.kt b/android/app/src/main/java/hum/daemon/WifiDirectManager.kt
new file mode 100644
index 0000000..53af19d
--- /dev/null
+++ b/android/app/src/main/java/hum/daemon/WifiDirectManager.kt
@@ -0,0 +1,119 @@
+package hum.daemon
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.wifi.p2p.WifiP2pDevice
+import android.net.wifi.p2p.WifiP2pDeviceList
+import android.net.wifi.p2p.WifiP2pGroup
+import android.net.wifi.p2p.WifiP2pInfo
+import android.net.wifi.p2p.WifiP2pManager
+import android.os.Build
+import android.util.Log
+
+/**
+ * WiFi Direct radio underlay for the native humd.
+ *
+ * The ensemble mesh (iroh QUIC + relay) is the *overlay* that reaches
+ * other humds — it works over any interface, including p2p0. WiFi
+ * Direct is the *underlay* that gives phones an ad-hoc L2 link without
+ * a router or internet: discovery, group formation, and the p2p0 IP.
+ *
+ * When a group forms, [onGroupFormed] hands the p2p0 address up to the
+ * host so the daemon can reach phone-to-phone peers directly (the
+ * meetup case). For phone-to-machine, iroh's relay + hole-punching
+ * needs no WiFi Direct at all.
+ */
+class WifiDirectManager(private val context: Context) {
+ companion object {
+ const val TAG = "humd.wifidirect"
+ }
+
+ var onPeerFound: (WifiP2pDevice) -> Unit = { }
+ var onGroupFormed: (isGroupOwner: Boolean, p2pIp: String?) -> Unit = { _, _ -> }
+
+ private var manager: WifiP2pManager? = null
+ private var channel: WifiP2pManager.Channel? = null
+ private var receiverRegistered = false
+
+ fun start(): Boolean {
+ manager = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
+ ?: run { Log.w(TAG, "no WifiP2p service on this device"); return false }
+ channel = manager?.initialize(context, context.mainLooper, null)
+ ?: return false
+
+ context.registerReceiver(
+ receiver,
+ IntentFilter().apply {
+ addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
+ },
+ Context.RECEIVER_EXPORTED
+ )
+ receiverRegistered = true
+ Log.i(TAG, "wifi-direct underlay up")
+ discover()
+ return true
+ }
+
+ fun stop() {
+ manager?.stopPeerDiscovery(channel, null)
+ if (receiverRegistered) {
+ try { context.unregisterReceiver(receiver) } catch (_: Exception) {}
+ receiverRegistered = false
+ }
+ }
+
+ private fun discover() {
+ val m = manager ?: return
+ val c = channel ?: return
+ m.discoverPeers(c, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() = Log.i(TAG, "p2p discovery started")
+ override fun onFailure(reason: Int) = Log.w(TAG, "p2p discovery failed reason=$reason")
+ })
+ }
+
+ fun connectTo(device: WifiP2pDevice) {
+ val m = manager ?: return
+ val c = channel ?: return
+ val cfg = WifiP2pManager.WifiP2pConfig().apply {
+ deviceAddress = device.deviceAddress
+ }
+ m.connect(c, cfg, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() = Log.i(TAG, "p2p connect initiated -> ${device.deviceName}")
+ override fun onFailure(reason: Int) = Log.w(TAG, "p2p connect failed reason=$reason")
+ })
+ }
+
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ when (intent.action) {
+ WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
+ val peers = intent.getParcelableExtra(
+ WifiP2pManager.EXTRA_WIFI_P2P_DEVICE_LIST
+ ) ?: return
+ for (d in peers.deviceList) {
+ Log.i(TAG, "found peer ${d.deviceName} @ ${d.deviceAddress}")
+ onPeerFound(d)
+ }
+ }
+ WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
+ val info = intent.getParcelableExtra(
+ WifiP2pManager.EXTRA_WIFI_P2P_INFO
+ ) ?: return
+ val ip = info.groupOwnerAddress?.hostAddress
+ Log.i(TAG, "group formed: go=${info.isGroupOwner} ip=$ip")
+ onGroupFormed(info.isGroupOwner, ip)
+ }
+ WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
+ val on = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) ==
+ WifiP2pManager.WIFI_P2P_STATE_ENABLED
+ Log.i(TAG, "wifi-p2p radio enabled=$on")
+ if (on) discover()
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..eccb45d
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ humd · android
+ humd running
+ native hum daemon · ensemble mesh active
+
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..3c5e113
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
diff --git a/android/scripts/build-humd.sh b/android/scripts/build-humd.sh
new file mode 100755
index 0000000..178e4a4
--- /dev/null
+++ b/android/scripts/build-humd.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Cross-compile the workspace `humd` daemon for aarch64-linux-android.
+#
+# "humd for Android" — the actual Rust daemon, built for the Android
+# target (PIE ELF), not a thin thrum client. This is the ensemble
+# portability problem space: the Rust workspace cross-compiles, the
+# NDK supplies the C toolchain (aws-lc / ring / quinn crypto) and the
+# Bionic linker. The resulting binary is copied into the Android app
+# as a bundled asset so the foreground service can exec it with no
+# Termux, no root.
+#
+# Prereqs:
+# rustup target add aarch64-linux-android
+# Android NDK (r27d) installed. Resolution order:
+# $ANDROID_NDK_HOME / $ANDROID_NDK_ROOT
+# ~/Library/Android/sdk/ndk/
+# /opt/android-ndk
+#
+# Usage:
+# ./android/scripts/build-humd.sh [--debug]
+set -euo pipefail
+cd "$(dirname "$0")/../.." # repo root
+
+TARGET=aarch64-linux-android
+API=${ANDROID_API:-21}
+
+# ── locate NDK ───────────────────────────────────────────────────────────────
+ndk=""
+for cand in "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_ROOT:-}" \
+ "$HOME/Library/Android/sdk/ndk"/android-ndk-* \
+ "$HOME/Library/Android/sdk/ndk"/r* \
+ /opt/android-ndk; do
+ if [ -n "$cand" ] && [ -d "$cand/toolchains/llvm/prebuilt" ]; then
+ ndk="$cand"; break
+ fi
+done
+if [ -z "$ndk" ]; then
+ echo "build-humd: no NDK found. Install it (brew install --cask android-commandlinetools; sdkmanager 'ndk;27d') or set ANDROID_NDK_HOME." >&2
+ exit 2
+fi
+
+# prebuilt host triplet: darwin-x86_64 on macOS Intel, darwin-aarch64 on AS
+host="$(ls "$ndk/toolchains/llvm/prebuilt" | head -1)"
+tc="$ndk/toolchains/llvm/prebuilt/$host/bin"
+clang="$tc/${TARGET}${API}-clang"
+if [ ! -x "$clang" ]; then
+ echo "build-humd: toolchain missing $clang" >&2
+ exit 2
+fi
+
+echo "ndk: $ndk"
+echo "target: $TARGET (API $API)"
+echo "clang: $clang"
+
+# ── cargo linker + C compiler wiring ─────────────────────────────────────────
+# The workspace's C deps (aws-lc-sys, ring) need a cross clang; the final
+# link needs the Bionic linker. We write a repo-local .cargo/config.toml
+# (gitignored) so `cargo build --target aarch64-linux-android` just works.
+mkdir -p .cargo
+cat > .cargo/config.toml < `-`), so use CC_aarch64_linux_android etc.
+export "CC_${TARGET//-/_}"="$clang"
+export "AR_${TARGET//-/_}"="$tc/llvm-ar"
+
+# ── build ────────────────────────────────────────────────────────────────────
+mode="release"
+[ "${1:-}" = "--debug" ] && mode="debug"
+echo "building humd ($mode)..."
+cargo build -p humd --target "$TARGET" ${mode/release/--release}
+
+bin="target/$TARGET/${mode/release/release}/humd"
+out="android/app/src/main/assets/humd"
+mkdir -p "$(dirname "$out")"
+cp -f "$bin" "$out"
+chmod 755 "$out"
+
+echo "→ bundled to $out ($(du -h "$out" | cut -f1))"
+file "$out"
+echo "build-humd: done"
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..b78c16d
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "humd-android"
+include(":app")