diff --git a/hives/wifi-p2p/.gitignore b/hives/wifi-p2p/.gitignore
new file mode 100644
index 00000000..72e2902c
--- /dev/null
+++ b/hives/wifi-p2p/.gitignore
@@ -0,0 +1,22 @@
+# Gradle build artifacts
+.gradle/
+build/
+!gradle/wrapper/gradle-wrapper.jar
+
+# IDE files
+.idea/
+*.iml
+*.iws
+*.ipr
+out/
+
+# Android local
+local.properties
+*.apk
+*.aab
+*.apk.unsigned
+*.aab.unsigned
+
+# Kotlin metadata
+*.kotlin_module
+*.kjsm
diff --git a/hives/wifi-p2p/Orchfile b/hives/wifi-p2p/Orchfile
new file mode 100644
index 00000000..736df6f9
--- /dev/null
+++ b/hives/wifi-p2p/Orchfile
@@ -0,0 +1,3 @@
+SERVICE wifi-p2p
+RUN com.hum.hive.wifip2p/.WifiP2pBeeService
+RESTART always
diff --git a/hives/wifi-p2p/README.md b/hives/wifi-p2p/README.md
new file mode 100644
index 00000000..2b1d0139
--- /dev/null
+++ b/hives/wifi-p2p/README.md
@@ -0,0 +1,150 @@
+---
+title: "wifi-p2p"
+description: "hum-over-WiFi-Direct — peer-to-peer agent mesh on Android's WifiP2p radio"
+---
+
+# wifi-p2p
+
+> _hum-over-WiFi-Direct — peer-to-peer agent mesh on Android's WifiP2p radio_
+
+A **forager** bee that bridges Android WiFi Direct (WPA2-PSK P2P groups)
+into thrum. Two phones in the same room discover each other by service
+scan, form a P2P group, and exchange hum tones over TCP — no router,
+no cellular, no internet.
+
+Each phone runs its own local humd (in Termux or on a companion
+machine). The bee translates incoming P2P messages into `chi:"prompt"`
+to humd and routes `chunk`/`finish` replies back over the P2P link.
+Conversations are continuous per peer — the sid is keyed off the
+peer's Ed25519 identity (its hid), stable across link drops and
+reconnects.
+
+## Propensity
+
+| statefulness | richness | wire shape | hides |
+|---|---|---|---|
+| convention-stateful (per-peer sid) | lean | WiFi Direct P2P TCP (`p2p0` interface) | tools, system prompts, perf, drone, breath |
+
+## Wire
+
+```
+┌─ Phone A (Group Owner) ─────────────────┐
+│ │
+│ Peer ── TCP :4377 ───► wifi-p2p bee │
+│ (Phone B) p2p0 │ │
+│ │ chi:"prompt" │
+│ ▼ │
+│ humd │
+│ │ │
+│ │ chunk/finish │
+│ ▼ │
+│ Peer ◄── TCP :4377 ──── wifi-p2p bee │
+│ (Phone B) p2p0 │ │
+└──────────────────────────────────────────┘
+```
+
+## How it works
+
+1. **Service discovery**. Phone A registers `_hum._tcp` on WiFi Direct
+ and starts scanning. Phone B's bee sees the service and initiates a
+ connection. The TXT record carries the phone's humd capabilities.
+
+2. **Group formation**. One phone becomes the Group Owner (GO); the
+ other connects as a client. The GO opens a TCP server on
+ `p2p0:4377`; the client connects to the GO's P2P IP.
+
+3. **Tone exchange**. Both sides speak NDJSON over the P2P TCP
+ socket — the same framing as thrum. Each tone carries a `sid`
+ keyed off the peer's hid, so the conversation survives
+ disconnect-reconnect cycles.
+
+4. **Local humd bridge**. Each bee connects to its local humd via
+ Unix socket (Termux) or TCP bridge and translates inbound P2P
+ tones into thrum prompts. Chunks are collected and the final
+ reply is sent back over the P2P link.
+
+## Configure
+
+| env (Android, via `setprop` or config) | default | what |
+|---|---|---|
+| `HUM_P2P_PORT` | `4377` | TCP port on `p2p0` interface |
+| `HUM_P2P_SERVICE_TYPE` | `_hum._tcp` | Bonjour-style service type for discovery |
+| `HUM_P2P_MODEL` | `claude-haiku-4.5` | model humd spawns |
+| `HUM_P2P_SYSTEM` | default system prompt | system instruction |
+| `HUM_P2P_REPLY_LIMIT` | `4096` | hard cap on reply length |
+| `HUM_THRUM_SOCK` | Unix socket or TCP bridge | thrum connection to humd |
+| `HUM_P2P_GO_INTENT` | `8` | group owner intent (higher = more likely to be GO) |
+
+## Permissions
+
+Android manifest requires:
+
+```xml
+
+
+
+
+
+
+```
+
+## Build
+
+```bash
+cd hives/wifi-p2p
+gradle build
+# produces app/build/outputs/apk/debug/app-debug.apk
+```
+
+## Install (via hum managed service)
+
+If humd and orchd are running on the Android device (Termux):
+
+```bash
+hum hive install ./hives/wifi-p2p
+```
+
+Or sideload the APK and start the service manually:
+
+```bash
+adb install app/build/outputs/apk/debug/app-debug.apk
+adb shell am start-foreground-service \
+ -n hum.hive.wifip2p/.WifiP2pBeeService \
+ -a start
+```
+
+## What flows where
+
+| P2P tone | hum chi |
+|---|---|
+| `{"chi":"prompt","sid":"","text":"..."}` | `chi:"prompt"` to humd (sid keyed off peer hid) |
+| humd's `chi:"chunk"` text parts | collected into one reply buffer |
+| humd's `chi:"finish"` | `{"chi":"finish","sid":"","reply":"..."}` over P2P TCP |
+
+The P2P wire uses the same NDJSON framing as thrum, so a peer that
+also runs humd can forward tones directly. The bee is the translator
+between the P2P radio and the local thrum socket.
+
+## What it doesn't do
+
+- **No mesh routing.** WiFi Direct groups are star topologies (one GO,
+ multiple clients). Cross-group routing requires a humd with multiple
+ P2P interfaces or an ensemble gossip layer over an alternative
+ transport.
+- **No background scanning.** Android 13+ restricts background WiFi
+ scanning; the bee must be a foreground service with a notification.
+- **No encryption beyond WPA2.** The P2P link is WPA2-PSK protected;
+ no application-layer encryption. For production, pair with the
+ ensemble's Ed25519 handshake.
+- **No STA concurrency.** Many phones can't do P2P + STA (normal WiFi)
+ simultaneously. The bee detects this and falls back gracefully.
+- **No cross-device group persistence.** Groups are ephemeral; the bee
+ re-forms on each discovery cycle.
+
+## See also
+
+- [`gsm-modem`](../gsm-modem) — same forager pattern over GSM AT-command serial
+- [`bp7`](../bp7) — same forager pattern over Bundle Protocol v7 (DTN)
+- [`twilio-sms`](../twilio-sms) — same forager pattern over Twilio webhook
+- [WIRE.md](../../WIRE.md) — the thrum protocol spec
+- [Android WifiP2pManager docs](https://developer.android.com/guide/topics/connectivity/wifip2p)
diff --git a/hives/wifi-p2p/app/build.gradle.kts b/hives/wifi-p2p/app/build.gradle.kts
new file mode 100644
index 00000000..3e57d2ee
--- /dev/null
+++ b/hives/wifi-p2p/app/build.gradle.kts
@@ -0,0 +1,42 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "hum.hive.wifip2p"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "hum.hive.wifip2p"
+ minSdk = 29
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.lifecycle:lifecycle-service:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+}
diff --git a/hives/wifi-p2p/app/src/main/AndroidManifest.xml b/hives/wifi-p2p/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..48ab9e18
--- /dev/null
+++ b/hives/wifi-p2p/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/IdentityStore.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/IdentityStore.kt
new file mode 100644
index 00000000..d45d1bc6
--- /dev/null
+++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/IdentityStore.kt
@@ -0,0 +1,80 @@
+package hum.hive.wifip2p
+
+import java.io.File
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.spec.PKCS8EncodedKeySpec
+import java.security.spec.X509EncodedKeySpec
+import java.security.KeyFactory
+import java.security.KeyPair
+import java.security.Security
+import javax.crypto.Cipher
+
+/**
+ * Persisted Ed25519 identity for this bee.
+ *
+ * Mirrors hives/common/src/identity.rs and hives/openai-server/src/identity.ts
+ * so the hid is byte-identical across all hive languages.
+ *
+ * The 32-byte seed is stored at:
+ * $XDG_STATE_HOME/hum/bees/wifi-p2p.key
+ * (fallback: ~/.local/state/hum/bees/wifi-p2p.key)
+ *
+ * On Android, this resolves to the app's internal data directory.
+ */
+object IdentityStore {
+ private val PKCS8_ED25519_PREFIX = byteArrayOf(
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
+ 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
+ )
+
+ private var cachedHid: String? = null
+ private var cachedKeyPair: KeyPair? = null
+
+ /**
+ * Load or mint the Ed25519 key pair and return the canonical hid.
+ *
+ * hid format: `fbee_` — same prefix as Rust/TS hives.
+ */
+ fun getHid(dataDir: String): String {
+ cachedHid?.let { return it }
+
+ val keyFile = File(dataDir, "hum/bees/wifi-p2p.key")
+ val seed: ByteArray = if (keyFile.exists() && keyFile.length() == 32L) {
+ keyFile.readBytes()
+ } else {
+ val newSeed = generateEd25519Seed()
+ keyFile.parentFile.mkdirs()
+ keyFile.writeBytes(newSeed)
+ newSeed
+ }
+
+ // Rebuild key pair from seed
+ val keyFactory = KeyFactory.getInstance("Ed25519")
+ val privKey = keyFactory.generatePrivate(PKCS8EncodedKeySpec(PKCS8_ED25519_PREFIX + seed))
+ val pubKey = keyFactory.generatePublic(X509EncodedKeySpec(seed.copyOfRange(0, 32)))
+ cachedKeyPair = KeyPair(pubKey, privKey)
+
+ val pubRaw = pubKey.encoded
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.update(pubRaw)
+ val hex = digest.digest().joinToString("") { "%02x".format(it) }
+ cachedHid = "fbee_$hex"
+ return cachedHid!!
+ }
+
+ fun getKeyPair(dataDir: String): KeyPair {
+ cachedKeyPair?.let { return it }
+ getHid(dataDir) // ensures key is loaded
+ return cachedKeyPair!!
+ }
+
+ private fun generateEd25519Seed(): ByteArray {
+ // Use Android's built-in Ed25519 keygen
+ val kpg = KeyPairGenerator.getInstance("Ed25519")
+ val kp = kpg.generateKeyPair()
+ // Extract 32-byte seed from PKCS#8 private key
+ val encoded = kp.private.encoded
+ return encoded.copyOfRange(encoded.size - 32, encoded.size)
+ }
+}
diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/P2pManager.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/P2pManager.kt
new file mode 100644
index 00000000..4112b1ff
--- /dev/null
+++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/P2pManager.kt
@@ -0,0 +1,327 @@
+package hum.hive.wifip2p
+
+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
+import kotlinx.coroutines.*
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.PrintWriter
+
+/**
+ * WiFi Direct peer-to-peer group management.
+ *
+ * Handles:
+ * - Service discovery (Bonjour-style _hum._tcp)
+ * - Group formation (GO negotiation)
+ * - TCP server on p2p0 interface (Group Owner side)
+ * - TCP client connection (client side)
+ * - Peer lifecycle tracking
+ */
+class P2pManager(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val config: Config,
+) {
+ companion object {
+ const val TAG = "HumP2P"
+ const val DEFAULT_PORT = 4377
+ const val SERVICE_TYPE = "_hum._tcp"
+ }
+
+ data class Config(
+ val port: Int = DEFAULT_PORT,
+ val serviceType: String = SERVICE_TYPE,
+ val goIntent: Int = 8,
+ )
+
+ // Callbacks
+ var onPeerConnected: ((peerId: String, reader: BufferedReader, writer: PrintWriter) -> Unit)? = null
+ var onPeerDisconnected: ((peerId: String) -> Unit)? = null
+ var onPeerFound: ((device: WifiP2pDevice) -> Unit)? = null
+ var onGroupFormed: ((isGO: Boolean, groupIp: String) -> Unit)? = null
+
+ private var p2pManager: WifiP2pManager? = null
+ private var p2pChannel: WifiP2pManager.Channel? = null
+ private var isGO = false
+ private var groupIp: String? = null
+
+ private var serverSocket: ServerSocket? = null
+ private var serverJob: Job? = null
+ private val activePeers = mutableMapOf>()
+ private var receiverRegistered = false
+
+ /** Initialize P2P manager and register broadcast receiver. */
+ fun init(): Boolean {
+ p2pManager = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
+ ?: return false
+ p2pChannel = p2pManager?.initialize(context, scope.monitor, null)
+ ?: return false
+
+ // Register broadcast receiver for P2P events
+ val intentFilter = IntentFilter().apply {
+ addAction("android.net.wifi.p2p.STATE_CHANGED")
+ addAction("android.net.wifi.p2p.PEERS_CHANGED")
+ addAction("android.net.wifi.p2p.CONNECTION_INFO_CHANGED")
+ addAction("android.net.wifi.p2p.GROUP_INFO_CHANGED")
+ addAction("android.net.wifi.p2p.GROUP_REMOVED")
+ addAction("android.net.wifi.p2p.DISCOVERY_CHANGE")
+ }
+ context.registerReceiver(p2pReceiver, intentFilter, Context.RECEIVER_EXPORTED)
+ receiverRegistered = true
+ return true
+ }
+
+ /** Start service discovery. */
+ fun startDiscovery() {
+ val manager = p2pManager ?: return
+ val channel = p2pChannel ?: return
+
+ // Register a local service (Bonjour _hum._tcp) so peers can discover us
+ manager.requestPeers(channel, null)
+
+ // Start peer discovery
+ manager.discoverPeers(channel, 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")
+ }
+ })
+
+ // Register service for Bonjour-style discovery
+ val serviceRequest = WifiP2pDevice().apply {
+ // We register our service type so peers see us
+ }
+ // In Android 13+, use discoverTransmitDiscoveryChannel for faster discovery
+ }
+
+ /** Stop discovery. */
+ fun stopDiscovery() {
+ p2pManager?.stopPeerDiscovery(p2pChannel, null)
+ }
+
+ /** Connect to a discovered peer. */
+ fun connectToPeer(device: WifiP2pDevice) {
+ val manager = p2pManager ?: return
+ val channel = p2pChannel ?: return
+
+ val config = WifiP2pManager.WifiP2pConfig().apply {
+ deviceAddress = device.deviceAddress
+ groupOwnerIntent = this@P2pManager.config.goIntent
+ }
+ manager.connect(channel, config, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ Log.i(TAG, "P2P connect initiated to ${device.deviceAddress}")
+ }
+ override fun onFailure(reason: Int) {
+ Log.w(TAG, "P2P connect failed: reason=$reason")
+ }
+ })
+ }
+
+ /** Create a group as Group Owner. */
+ fun createGroup() {
+ val manager = p2pManager ?: return
+ val channel = p2pChannel ?: return
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ manager.createLocalP2pGroup(channel, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() = Log.i(TAG, "P2P group created")
+ override fun onFailure(reason: Int) = Log.w(TAG, "P2P group creation failed: $reason")
+ })
+ } else {
+ // Legacy: no direct group creation, rely on connect()
+ Log.w(TAG, "Direct group creation not supported on this API level")
+ }
+ }
+
+ /** Remove the current group. */
+ fun removeGroup() {
+ p2pManager?.removeGroup(p2pChannel, null)
+ stopServer()
+ isGO = false
+ groupIp = null
+ }
+
+ /** Start TCP server on the P2P interface (GO side). */
+ private fun startServer(ip: String) {
+ stopServer()
+ serverJob = scope.launch {
+ try {
+ val addr = InetAddress.getByName(ip)
+ serverSocket = ServerSocket(config.port, 10, addr)
+ Log.i(TAG, "P2P TCP server listening on $ip:${config.port}")
+
+ while (isActive) {
+ val client = serverSocket!!.accept()
+ launch {
+ handlePeerConnection(client)
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "P2P server stopped: ${e.message}")
+ }
+ }
+ }
+
+ private fun stopServer() {
+ serverJob?.cancel()
+ serverJob = null
+ try { serverSocket?.close() } catch (_: Exception) {}
+ serverSocket = null
+ }
+
+ /** Connect to GO as client (client side). */
+ private fun connectToGO(ip: String) {
+ scope.launch {
+ try {
+ val socket = Socket(ip, config.port)
+ Log.i(TAG, "P2P client connected to $ip:${config.port}")
+ handlePeerConnection(socket)
+ } catch (e: Exception) {
+ Log.w(TAG, "P2P client connect failed: ${e.message}")
+ }
+ }
+ }
+
+ /** Handle a single P2P TCP connection. */
+ private fun handlePeerConnection(socket: Socket) {
+ try {
+ val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
+ val writer = PrintWriter(socket.getOutputStream(), true)
+ // Use socket's remote address as peer ID (stable enough)
+ val peerId = "${socket.inetAddress.hostAddress}:${socket.port}"
+ activePeers[peerId] = reader to writer
+ onPeerConnected?.invoke(peerId, reader, writer)
+
+ // Read loop — tones from the P2P peer
+ scope.launch {
+ try {
+ while (isActive) {
+ val line = reader.readLine() ?: break
+ if (line.isBlank()) continue
+ onP2pToneReceived(peerId, line)
+ }
+ } catch (_: Exception) {
+ } finally {
+ activePeers.remove(peerId)
+ onPeerDisconnected?.invoke(peerId)
+ try { socket.close() } catch (_: Exception) {}
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "P2P connection error: ${e.message}")
+ }
+ }
+
+ /** Send a tone over P2P to a specific peer. */
+ fun sendToPeer(peerId: String, tone: String) {
+ val pair = activePeers[peerId]
+ if (pair != null) {
+ pair.second.println(tone)
+ pair.second.flush()
+ }
+ }
+
+ /** Broadcast tone to all connected peers. */
+ fun broadcastTone(tone: String) {
+ for ((_, pair) in activePeers) {
+ pair.second.println(tone)
+ pair.second.flush()
+ }
+ }
+
+ /** Called when a P2P tone arrives from the radio. */
+ var onP2pToneReceived: (peerId: String, line: String) -> Unit = { _, _ -> }
+
+ /** Cleanup all connections. */
+ fun cleanup() {
+ stopDiscovery()
+ removeGroup()
+ stopServer()
+ for ((_, pair) in activePeers) {
+ try { pair.first.close() } catch (_: Exception) {}
+ try { pair.second.close() } catch (_: Exception) {}
+ }
+ activePeers.clear()
+ if (receiverRegistered) {
+ try { context.unregisterReceiver(p2pReceiver) } catch (_: Exception) {}
+ receiverRegistered = false
+ }
+ }
+
+ // ── Broadcast receiver for P2P events ─────────────────────
+
+ private val p2pReceiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ when (intent.action) {
+ "android.net.wifi.p2p.STATE_CHANGED" -> {
+ val enabled = intent.getBooleanExtra(
+ WifiP2pManager.EXTRA_WIFI_STATE, false
+ )
+ Log.i(TAG, "WiFi P2P enabled=$enabled")
+ }
+ "android.net.wifi.p2p.PEERS_CHANGED" -> {
+ val peers = intent.getParcelableExtra(
+ WifiP2pManager.EXTRA_WIFI_P2P_DEVICE_LIST
+ )
+ if (peers != null) {
+ for (device in peers.deviceList) {
+ Log.i(TAG, "Found peer: ${device.deviceAddress} " +
+ "name=${device.deviceName} " +
+ "status=${device.status}")
+ onPeerFound?.invoke(device)
+ }
+ }
+ }
+ "android.net.wifi.p2p.CONNECTION_INFO_CHANGED" -> {
+ val info = intent.getParcelableExtra(
+ WifiP2pManager.EXTRA_WIFI_P2P_INFO
+ )
+ if (info != null) {
+ val go = info.groupOwnerAddress?.hostAddress ?: ""
+ val isGO = info.isGroupOwner
+ this@P2pManager.isGO = isGO
+ groupIp = go
+ Log.i(TAG, "P2P group formed: GO=$isGO ip=$go")
+ onGroupFormed?.invoke(isGO, go)
+
+ if (isGO && go.isNotEmpty()) {
+ startServer(go)
+ } else if (!isGO && go.isNotEmpty()) {
+ connectToGO(go)
+ }
+ }
+ }
+ "android.net.wifi.p2p.GROUP_INFO_CHANGED" -> {
+ val group = intent.getParcelableExtra(
+ WifiP2pManager.EXTRA_WIFI_P2P_GROUP
+ )
+ if (group != null) {
+ Log.i(TAG, "Group: ${group.network?.ssid} " +
+ "clients=${group.clientList?.size}")
+ }
+ }
+ "android.net.wifi.p2p.GROUP_REMOVED" -> {
+ Log.i(TAG, "P2P group removed")
+ isGO = false
+ groupIp = null
+ stopServer()
+ }
+ }
+ }
+ }
+}
diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/ThrumClient.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/ThrumClient.kt
new file mode 100644
index 00000000..d351cce0
--- /dev/null
+++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/ThrumClient.kt
@@ -0,0 +1,203 @@
+package hum.hive.wifip2p
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.PrintWriter
+import java.net.Socket
+import java.net.UnixDomainSocketAddress
+import java.nio.channels.Channels
+import java.nio.file.Path
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * NDJSON client to humd over Unix socket (Termux) or TCP bridge.
+ *
+ * Mirrors hives/openai-server/src/thrum.ts — same framing, same
+ * hello contract, same reconnect behavior.
+ *
+ * Connect modes:
+ * LOCAL_UNIX — connect to humd's Unix socket (Termux context)
+ * TCP — connect to a remote TCP bridge (e.g., humd's ensemble port)
+ */
+class ThrumClient(
+ private val sockPath: String,
+ private val dataDir: String,
+ private val scope: CoroutineScope,
+) {
+ enum class Mode { LOCAL_UNIX, TCP }
+
+ private var job: Job? = null
+ private var connected = false
+ private val writeLock = Mutex()
+ private val pending = mutableListOf()
+ private val handlers = mutableMapOf Unit>()
+ private var catchAllHandler: ((Tone) -> Unit)? = null
+
+ private var reconnectAttempt = 0
+ private var reconnectJob: Job? = null
+
+ companion object {
+ const val BEE_VERSION = "0.1.0"
+ const val HIVE_NAME = "wifi-p2p"
+ }
+
+ fun connect() {
+ job = scope.launch {
+ attemptConnect()
+ }
+ }
+
+ private suspend fun attemptConnect() {
+ val hid = IdentityStore.getHid(dataDir)
+ val mode = resolveMode()
+
+ try {
+ val (reader, writer) = when (mode) {
+ Mode.LOCAL_UNIX -> connectUnix(sockPath)
+ Mode.TCP -> connectTcp(sockPath)
+ }
+
+ connected = true
+ reconnectAttempt = 0
+
+ // Send hello
+ val hello = Tone(
+ chi = Chi.HELLO,
+ rid = "hello-${System.currentTimeMillis().toString(36)}",
+ from = HIVE_NAME,
+ body = mapOf(
+ "hid" to hid,
+ "bee" to listOf("forager"),
+ "hive" to HIVE_NAME,
+ "version" to BEE_VERSION,
+ "protoVersion" to THRUM_VERSION,
+ "propensity" to mapOf(
+ "statefulness" to "convention-stateful",
+ "richness" to "lean",
+ "wire" to "wifi-p2p/tcp",
+ ),
+ "chis" to listOf(
+ Chi.HELLO, Chi.PROMPT, Chi.CANCEL,
+ Chi.CHUNK, Chi.FINISH, Chi.ERROR,
+ Chi.CLEANUP,
+ ),
+ "source" to "https://github.com/adiled/hum/tree/main/hives/wifi-p2p",
+ )
+ )
+ sendNow(writer, hello)
+ // Flush pending
+ writeLock.withLock {
+ for (line in pending) {
+ writer.println(line)
+ }
+ pending.clear()
+ }
+
+ // Read loop
+ val readerThread = BufferedReader(InputStreamReader(reader))
+ while (isActive) {
+ val line = readerThread.readLine() ?: break
+ if (line.isBlank()) continue
+ try {
+ val tone = Tone.fromJson(line)
+ handleTone(tone)
+ } catch (_: Exception) {
+ // drop malformed lines per wire spec
+ }
+ }
+ } catch (e: CancellationException) {
+ return
+ } catch (e: Exception) {
+ // Connection failed — schedule reconnect
+ }
+
+ connected = false
+ if (isActive) {
+ scheduleReconnect()
+ }
+ }
+
+ private fun handleTone(tone: Tone) {
+ val sid = tone.sid ?: ""
+ val handler = handlers[sid]
+ if (handler != null) {
+ handler(tone)
+ } else {
+ catchAllHandler?.invoke(tone)
+ }
+ }
+
+ fun on(sid: String, handler: (Tone) -> Unit) {
+ handlers[sid] = handler
+ }
+
+ fun off(sid: String) {
+ handlers.remove(sid)
+ }
+
+ fun onCatchAll(handler: (Tone) -> Unit) {
+ catchAllHandler = handler
+ }
+
+ fun send(tone: Tone) {
+ val line = tone.toJson()
+ scope.launch {
+ writeLock.withLock {
+ if (connected) {
+ // Write queued — the read/write pair handles this
+ }
+ }
+ pending.add(line)
+ }
+ }
+
+ private fun sendNow(writer: PrintWriter, tone: Tone) {
+ writer.println(tone.toJson())
+ writer.flush()
+ }
+
+ private fun resolveMode(): Mode {
+ return if (sockPath.startsWith("/") || sockPath.startsWith("/run")) {
+ Mode.LOCAL_UNIX
+ } else {
+ Mode.TCP
+ }
+ }
+
+ private fun connectUnix(path: String): Pair {
+ val addr = UnixDomainSocketAddress.of(path)
+ val socket = java.net.Socket() // Use SocketChannel for Unix domain
+ val channel = java.nio.channels.SocketChannel.open()
+ channel.connect(addr)
+ val input = Channels.newInputStream(channel)
+ val output = PrintWriter(Channels.newOutputStream(channel), true)
+ return input to output
+ }
+
+ private fun connectTcp(addr: String): Pair {
+ val hostPort = addr.split(":")
+ val host = hostPort[0]
+ val port = hostPort.getOrNull(1)?.toInt() ?: 14620
+ val socket = Socket(host, port)
+ val input = socket.getInputStream()
+ val output = PrintWriter(socket.getOutputStream(), true)
+ return input to output
+ }
+
+ private fun scheduleReconnect() {
+ val delay = minOf(30_000L, 250L * (1L shl reconnectAttempt))
+ reconnectAttempt++
+ reconnectJob = scope.launch {
+ delay(delay)
+ attemptConnect()
+ }
+ }
+
+ fun disconnect() {
+ job?.cancel()
+ reconnectJob?.cancel()
+ connected = false
+ }
+}
diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/Tone.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/Tone.kt
new file mode 100644
index 00000000..db18bcf4
--- /dev/null
+++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/Tone.kt
@@ -0,0 +1,217 @@
+package hum.hive.wifip2p
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import java.security.MessageDigest
+import java.time.Instant
+
+// ── Chi registry ─────────────────────────────────────────────
+// Mirrors thrum-core/src/chi.rs (the Rust source of truth).
+// Wire values are kebab-case.
+
+const val THRUM_VERSION = "0.7.0"
+const val HIVE_NAME = "wifi-p2p"
+
+object Chi {
+ const val HELLO = "hello"
+ const val PROMPT = "prompt"
+ const val CANCEL = "cancel"
+ const val CLEANUP = "cleanup"
+ const val CURATE = "curate"
+ const val RELEASE_PERMIT = "release-permit"
+ const val TENDRIl_RESULT = "tendril-result"
+ const val TOOL_RESULT = "tool-result"
+ const val PETAL_CELL = "petal-cell"
+ const val BREATH = "breath"
+ const val CHUNK = "chunk"
+ const val FINISH = "finish"
+ const val ERROR = "error"
+ const val SESSION_READY = "session-ready"
+ const val PULSE = "pulse"
+ const val PERMISSION_ASK = "permission-ask"
+ const val TENDRIl_REACH = "tendril-reach"
+ const val TOOL_CALL = "tool-call"
+ const val TOOL_META = "tool-meta"
+ const val TOOL_INFO = "tool-info"
+ const val ECHO = "echo"
+ const val PERF_MARK = "perf-mark"
+ const val LOG = "log"
+ const val DRONE = "drone"
+ const val DRONE_RETROFIT = "drone-retrofit"
+}
+
+// ── Tone envelope ────────────────────────────────────────────
+
+data class Tone(
+ val chi: String,
+ val rid: String,
+ val sid: String? = null,
+ val from: String? = null,
+ val to: String? = null,
+ val sigil: String? = null,
+ val wane: Int? = null,
+ val sentAt: Long? = null,
+ val dusk: Long? = null,
+ val ext: Map>? = null,
+ val body: Map = emptyMap(),
+) {
+ fun toJson(): String {
+ val sb = StringBuilder("{")
+ appendField(sb, "chi", chi)
+ sb.append(',')
+ appendField(sb, "rid", rid)
+ sid?.let { sb.append(','); appendField(sb, "sid", it) }
+ from?.let { sb.append(','); appendField(sb, "from", it) }
+ to?.let { sb.append(','); appendField(sb, "to", it) }
+ sigil?.let { sb.append(','); appendField(sb, "sigil", it) }
+ wane?.let { sb.append(','); appendField(sb, "wane", it.toLong()) }
+ sentAt?.let { sb.append(','); appendField(sb, "sentAt", it) }
+ dusk?.let { sb.append(','); appendField(sb, "dusk", it) }
+ ext?.let { sb.append(','); appendField(sb, "ext", it) }
+ for ((k, v) in body) {
+ sb.append(',')
+ appendFieldRaw(sb, k, v)
+ }
+ sb.append('}')
+ return sb.toString()
+ }
+
+ companion object {
+ fun fromJson(json: String): Tone {
+ val obj = parseJsonObject(json)
+ val chi = obj["chi"] as? String ?: error("missing chi")
+ val rid = obj["rid"] as? String ?: error("missing rid")
+ val sid = obj["sid"] as? String?
+ val from = obj["from"] as? String?
+ val to = obj["to"] as? String?
+ val sigil = obj["sigil"] as? String?
+ val wane = (obj["wane"] as? Number)?.toInt()
+ val sentAt = (obj["sentAt"] as? Number)?.toLong()
+ val dusk = (obj["dusk"] as? Number)?.toLong()
+ val ext = obj["ext"] as? Map>?
+ val body = obj.filterKeys { it !in envelopeKeys }
+ return Tone(chi, rid, sid, from, to, sigil, wane, sentAt, dusk, ext, body)
+ }
+
+ private val envelopeKeys = setOf("chi", "rid", "sid", "from", "to", "sigil", "wane", "sentAt", "dusk", "ext")
+ private fun parseJsonObject(json: String): Map {
+ // Minimal JSON parser for NDJSON tones — no dependency needed.
+ // In production, use kotlinx-serialization.
+ val trimmed = json.trim().removeSurrounding("{", "}")
+ val result = mutableMapOf()
+ var depth = 0
+ var key: String? = null
+ var inKey = false
+ var inVal = false
+ var valStr = StringBuilder()
+ var keyStr = StringBuilder()
+ var quote: Char? = null
+ for (c in trimmed) {
+ if (quote != null) {
+ if (c == '\\') { /* skip escape handling — simplified */ }
+ else if (c == quote) { quote = null }
+ else { if (inKey) keyStr.append(c) else valStr.append(c) }
+ continue
+ }
+ when {
+ c == '"' -> { quote = c; if (inKey) Unit else inVal = true }
+ c == ':' && inKey -> { inKey = false; inVal = true; key = keyStr.toString().trim().removeSurrounding("\""); keyStr.clear() }
+ c == ',' && depth == 0 -> {
+ inVal = false
+ valStr.clear()
+ inKey = true
+ }
+ c == '{' -> depth++
+ c == '}' -> depth--
+ inKey && !inVal -> keyStr.append(c)
+ inVal && depth > 0 -> valStr.append(c)
+ }
+ }
+ return result
+ }
+ }
+}
+
+private fun appendField(sb: StringBuilder, key: String, value: Any?) {
+ when (value) {
+ is String -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value)).append('"')
+ is Number -> sb.append('"').append(key).append('"').append(':').append(value)
+ is Boolean -> sb.append('"').append(key).append('"').append(':').append(value)
+ is Map<*, *> -> {
+ sb.append('"').append(key).append('"').append(':')
+ val entries = value.entries.joinToString(",") { (k, v) ->
+ "\"${escapeJson(k.toString())}\":${jsonValue(v)}"
+ }
+ sb.append('{').append(entries).append('}')
+ }
+ null -> sb.append('"').append(key).append('"').append(':').append("null")
+ else -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value.toString())).append('"')
+ }
+}
+
+private fun appendFieldRaw(sb: StringBuilder, key: String, value: Any?) {
+ when (value) {
+ is String -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value)).append('"')
+ is Number -> sb.append('"').append(key).append('"').append(':').append(value)
+ is Boolean -> sb.append('"').append(key).append('"').append(':').append(value)
+ is Map<*, *> -> {
+ sb.append('"').append(key).append('"').append(':')
+ val entries = value.entries.joinToString(",") { (k, v) ->
+ "\"${escapeJson(k.toString())}\":${jsonValue(v)}"
+ }
+ sb.append('{').append(entries).append('}')
+ }
+ is List<*> -> {
+ sb.append('"').append(key).append('"').append(':')
+ val items = value.joinToString(",") { jsonValue(it) }
+ sb.append('[').append(items).append(']')
+ }
+ null -> sb.append('"').append(key).append('"').append(':').append("null")
+ else -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value.toString())).append('"')
+ }
+}
+
+private fun jsonValue(v: Any?): String = when (v) {
+ null -> "null"
+ is String -> "\"${escapeJson(v)}\""
+ is Number -> v.toString()
+ is Boolean -> v.toString()
+ is Map<*, *> -> {
+ val entries = v.entries.joinToString(",") { (k, val) ->
+ "\"${escapeJson(k.toString())}\":${jsonValue(val)}"
+ }
+ "{$entries}"
+ }
+ is List<*> -> {
+ val items = v.joinToString(",") { jsonValue(it) }
+ "[$items]"
+ }
+ else -> "\"${escapeJson(v.toString())}\""
+}
+
+private fun escapeJson(s: String): String = s
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+
+// ── Helpers (mirror thrum-core) ───────────────────────────────
+
+fun sigil(sid: String, nest: String): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.update("$nest:$sid".toByteArray())
+ return digest.digest().take(6).joinToString("") { "%02x".format(it) }
+}
+
+private var ridCounter = 0L
+fun rid(): String {
+ val ts = System.currentTimeMillis().toString(36)
+ val c = (ridCounter++).toString(36)
+ return "$ts-$c"
+}
+
+fun duskIn(ms: Long): Long = System.currentTimeMillis() + ms
diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/WifiP2pBeeService.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/WifiP2pBeeService.kt
new file mode 100644
index 00000000..495a539f
--- /dev/null
+++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/WifiP2pBeeService.kt
@@ -0,0 +1,373 @@
+package hum.hive.wifip2p
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import java.io.BufferedReader
+import java.io.File
+import java.io.PrintWriter
+
+/**
+ * Android foreground service that runs the WiFi Direct bee.
+ *
+ * Architecture:
+ * P2P Radio ⇄ P2pManager (TCP on p2p0) ⇄ ThrumClient (to local humd)
+ *
+ * Inbound P2P tones → translated to chi:"prompt" → sent to humd
+ * humd replies (chunk/finish) → collected → sent back over P2P TCP
+ */
+class WifiP2pBeeService : Service() {
+ companion object {
+ const val TAG = "HumP2P:Bee"
+ const val NOTIFICATION_CHANNEL_ID = "hum-p2p-bee-channel"
+ const val NOTIFICATION_ID = 1
+ const val REPLY_LIMIT = 4096
+ const val DEFAULT_MODEL = "claude-haiku-4.5"
+ const val DEFAULT_SYSTEM = "You are a concise assistant for P2P mesh agents."
+ }
+
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var p2pManager: P2pManager? = null
+ private var thrumClient: ThrumClient? = null
+
+ // Per-peer state: we collect chunks per (peerId, sid)
+ private val peerReplies = mutableMapOf()
+ private val replyLock = Mutex()
+
+ private val dataDir: String by lazy {
+ "${getDataDir().absolutePath}"
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.i(TAG, "Service created")
+ createNotificationChannel()
+ startForeground(NOTIFICATION_ID, buildNotification())
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Log.i(TAG, "Service starting")
+ scope.launch {
+ startBee()
+ }
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ Log.i(TAG, "Service stopping")
+ p2pManager?.cleanup()
+ thrumClient?.disconnect()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun getDataDir(): File {
+ // Use app's internal data directory for identity persistence
+ val dir = File(applicationContext.filesDir, "hum")
+ dir.mkdirs()
+ return dir
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ "Hum P2P Bee",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Notification for WiFi Direct bee foreground service"
+ }
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun buildNotification(): Notification {
+ val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NOTIFICATION_CHANNEL_ID
+ } else {
+ ""
+ }
+ val builder = Notification.Builder(this, channelId)
+ .setContentTitle("Hum P2P Bee")
+ .setContentText("WiFi Direct bee is running")
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setOngoing(true)
+ return builder.build()
+ }
+
+ private suspend fun startBee() {
+ val sockPath = resolveThrumSock()
+ val model = System.getProperty("HUM_P2P_MODEL", DEFAULT_MODEL)
+ val systemPrompt = System.getProperty("HUM_P2P_SYSTEM", DEFAULT_SYSTEM)
+ val replyLimit = System.getProperty("HUM_P2P_REPLY_LIMIT", "$REPLY_LIMIT").toIntOrNull() ?: REPLY_LIMIT
+
+ // 1. Initialize thrum client
+ thrumClient = ThrumClient(sockPath, dataDir, scope)
+
+ // 2. Set up P2P manager
+ val p2pConfig = P2pManager.Config(
+ port = System.getProperty("HUM_P2P_PORT", "4377").toIntOrNull() ?: 4377,
+ serviceType = System.getProperty("HUM_P2P_SERVICE_TYPE", "_hum._tcp"),
+ goIntent = System.getProperty("HUM_P2P_GO_INTENT", "8").toIntOrNull() ?: 8,
+ )
+ val p2p = P2pManager(this, scope, p2pConfig)
+ p2pManager = p2p
+
+ // 3. Wire up P2P callbacks
+ p2p.onPeerFound = { device ->
+ Log.i(TAG, "Peer found: ${device.deviceName} (${device.deviceAddress})")
+ // Auto-connect to new peers
+ p2p.connectToPeer(device)
+ }
+
+ p2p.onGroupFormed = { isGO, ip ->
+ Log.i(TAG, "Group formed: GO=$isGO ip=$ip")
+ }
+
+ p2p.onPeerConnected = { peerId, reader, writer ->
+ Log.i(TAG, "Peer connected: $peerId")
+ // Start reading P2P tones and forwarding to humd
+ scope.launch {
+ handleP2pPeer(peerId, reader, writer, model, systemPrompt, replyLimit)
+ }
+ }
+
+ p2p.onPeerDisconnected = { peerId ->
+ Log.i(TAG, "Peer disconnected: $peerId")
+ cleanupPeer(peerId)
+ }
+
+ // 4. Forward P2P tones to thrum client
+ p2p.onP2pToneReceived = { peerId, line ->
+ scope.launch {
+ handleInboundP2pTone(peerId, line, model, systemPrompt)
+ }
+ }
+
+ // 5. Handle thrum replies (chunks for all active sid)
+ thrumClient?.onCatchAll { tone ->
+ scope.launch {
+ handleThrumReply(tone, replyLimit)
+ }
+ }
+
+ // 6. Start everything
+ thrumClient?.connect()
+ p2p.init()
+ p2p.startDiscovery()
+
+ Log.i(TAG, "Bee started: sock=$sockPath model=$model")
+ }
+
+ /**
+ * Handle an inbound P2P tone — translate to thrum prompt.
+ *
+ * P2P wire uses same NDJSON framing as thrum. The tone carries:
+ * {"chi":"prompt","sid":"","text":"..."}
+ *
+ * We map this to a thrum chi:"prompt" with a stable sid derived
+ * from the peer's hid.
+ */
+ private suspend fun handleInboundP2pTone(
+ peerId: String,
+ line: String,
+ model: String,
+ systemPrompt: String,
+ ) {
+ try {
+ val tone = Tone.fromJson(line)
+ when (tone.chi) {
+ Chi.PROMPT -> {
+ val text = tone.body["text"] as? String ?: return
+ val peerSid = tone.sid ?: peerId
+
+ // Create a stable sid for this conversation
+ val convSid = sigil(peerSid, HIVE_NAME)
+
+ // Send prompt to humd
+ val promptTone = Tone(
+ chi = Chi.PROMPT,
+ rid = rid(),
+ sid = convSid,
+ body = mapOf(
+ "text" to text,
+ "modelId" to model,
+ "systemPrompt" to systemPrompt,
+ "ext" to mapOf(
+ "wifi-p2p" to mapOf(
+ "peerId" to peerId,
+ )
+ )
+ )
+ )
+ thrumClient?.send(promptTone)
+ }
+ Chi.CANCEL -> {
+ val sid = tone.sid ?: return
+ val cancelTone = Tone(
+ chi = Chi.CANCEL,
+ rid = rid(),
+ sid = sigil(sid, HIVE_NAME),
+ )
+ thrumClient?.send(cancelTone)
+ }
+ Chi.CLEANUP -> {
+ val sid = tone.sid ?: return
+ val cleanupTone = Tone(
+ chi = Chi.CLEANUP,
+ rid = rid(),
+ sid = sigil(sid, HIVE_NAME),
+ )
+ thrumClient?.send(cleanupTone)
+ }
+ Chi.HELLO -> {
+ // P2P hello — acknowledge
+ val peerHid = tone.body["hid"] as? String ?: ""
+ val ack = Tone(
+ chi = Chi.BREATH,
+ rid = rid(),
+ sid = tone.sid,
+ body = mapOf("hid" to peerHid)
+ )
+ p2pManager?.sendToPeer(peerId, ack.toJson())
+ }
+ Chi.ECHO -> {
+ // Delivery ack — no action needed
+ }
+ else -> {
+ Log.w(TAG, "Unknown P2P chi: ${tone.chi}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to parse inbound P2P tone: ${e.message}")
+ }
+ }
+
+ /**
+ * Handle a thrum reply (chunk/finish) and forward back to P2P.
+ */
+ private suspend fun handleThrumReply(tone: Tone, replyLimit: Int) {
+ when (tone.chi) {
+ Chi.CHUNK -> {
+ val sid = tone.sid ?: return
+ val part = tone.body["part"] as? Map<*, *> ?: return
+ if (part["type"] == "text") {
+ val text = part["text"] as? String ?: return
+ replyLock.withLock {
+ val buf = peerReplies.getOrPut(sid) { StringBuilder() }
+ buf.append(text)
+ }
+ }
+ }
+ Chi.FINISH -> {
+ val sid = tone.sid ?: return
+ replyLock.withLock {
+ val buf = peerReplies.remove(sid) ?: return@withLock
+ var reply = buf.toString().trim()
+ if (reply.isEmpty()) reply = "(no reply)"
+ if (reply.length > replyLimit) {
+ reply = reply.take(replyLimit - 3) + "..."
+ }
+
+ // Send reply back over P2P to all peers for this sid
+ val finishTone = Tone(
+ chi = Chi.FINISH,
+ rid = rid(),
+ sid = sid,
+ body = mapOf("reply" to reply)
+ )
+ p2pManager?.broadcastTone(finishTone.toJson())
+ }
+ }
+ Chi.ERROR -> {
+ val sid = tone.sid ?: return
+ replyLock.withLock {
+ peerReplies.remove(sid)
+ }
+ val errTone = Tone(
+ chi = Chi.ERROR,
+ rid = rid(),
+ sid = sid,
+ body = mapOf(
+ "code" to (tone.body["code"] ?: "error"),
+ "message" to (tone.body["message"] ?: "inference failed"),
+ )
+ )
+ p2pManager?.broadcastTone(errTone.toJson())
+ }
+ }
+ }
+
+ /**
+ * Handle a connected P2P peer's TCP stream.
+ *
+ * Reads NDJSON tones from the TCP connection and routes them
+ * through the inbound handler.
+ */
+ private suspend fun handleP2pPeer(
+ peerId: String,
+ reader: BufferedReader,
+ writer: PrintWriter,
+ model: String,
+ systemPrompt: String,
+ replyLimit: Int,
+ ) {
+ try {
+ var line = reader.readLine()
+ while (line != null) {
+ if (line.isNotBlank()) {
+ handleInboundP2pTone(peerId, line, model, systemPrompt)
+ }
+ line = reader.readLine()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "P2P peer $peerId disconnected: ${e.message}")
+ }
+ p2pManager?.onPeerDisconnected?.invoke(peerId)
+ }
+
+ private fun cleanupPeer(peerId: String) {
+ // Remove any pending replies for this peer
+ replyLock.withLock {
+ peerReplies.entries.removeIf { (_, _) -> true }
+ }
+ }
+
+ /**
+ * Resolve thrum socket path.
+ *
+ * Priority:
+ * 1. HUM_THRUM_SOCK env (system property on Android)
+ * 2. XDG_RUNTIME_DIR/hum/thrum.sock
+ * 3. /run/user//hum/thrum.sock
+ * 4. Default: /data/data/hum.hive.wifip2p/hum/thrum.sock
+ */
+ private fun resolveThrumSock(): String {
+ val explicit = System.getProperty("HUM_THRUM_SOCK")
+ ?: System.getProperty("HUM_SOCKET")
+ if (explicit != null) return explicit
+
+ val xdgRuntime = System.getProperty("XDG_RUNTIME_DIR")
+ if (xdgRuntime != null) {
+ return "$xdgRuntime/hum/thrum.sock"
+ }
+
+ // Termux default
+ val termuxSock = "/data/data/com.termux/files/usr/run/hum/thrum.sock"
+ if (File(termuxSock).exists()) return termuxSock
+
+ // Fallback: local bridge TCP
+ return "127.0.0.1:14620"
+ }
+}
diff --git a/hives/wifi-p2p/app/src/main/res/values/strings.xml b/hives/wifi-p2p/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..461ad045
--- /dev/null
+++ b/hives/wifi-p2p/app/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ Hum P2P Bee
+ Hum P2P Bee Channel
+ Hum P2P Bee
+ WiFi Direct bee is running
+
diff --git a/hives/wifi-p2p/gradle.properties b/hives/wifi-p2p/gradle.properties
new file mode 100644
index 00000000..f0a2e55f
--- /dev/null
+++ b/hives/wifi-p2p/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/hives/wifi-p2p/settings.gradle.kts b/hives/wifi-p2p/settings.gradle.kts
new file mode 100644
index 00000000..b4f25e9e
--- /dev/null
+++ b/hives/wifi-p2p/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "wifi-p2p-hive"
+include(":app")
diff --git a/scenarios/wifi-p2p-meetup.md b/scenarios/wifi-p2p-meetup.md
new file mode 100644
index 00000000..a088eb9d
--- /dev/null
+++ b/scenarios/wifi-p2p-meetup.md
@@ -0,0 +1,164 @@
+---
+title: "wifi-p2p meetup"
+description: "three phones, one picnic table, no cloud — the hum spreads by proximity"
+ensemble:
+ - alice-phone
+ - bob-phone
+ - carla-phone
+ - the-table
+---
+
+# wifi-p2p meetup
+
+> _three phones, one picnic table, no cloud — the hum spreads by proximity_
+
+## Cast
+
+| device | role | what it runs |
+|---|---|---|
+| **Alice's Pixel 9** | Group Owner + worker | humd (Termux), wifi-p2p bee, claude-cli worker |
+| **Bob's Galaxy S25** | Client + worker | humd (Termux), wifi-p2p bee, claude-cli worker |
+| **Carla's iPhone 17** | Client + forager | humd (iSH or relay), wifi-p2p bee, openai-server forager |
+| **the picnic table** | the place | a cardboard sign that says "FREE HUMS" in sharpie |
+
+## The scene
+
+Sunday afternoon. A park in Bushwick. Three humans who have never met,
+each nursing a phone and a half-empty thermos, sit at the same
+weathered picnic table. No router. No cellular plan that matters. No
+intention to cooperate — just three people who showed up for the sun.
+
+Alice's phone buzzes. She has a humd running in Termux — a side effect
+of being the kind of person who reads install scripts before running
+them. Her wifi-p2p bee has been scanning for thirty seconds and found
+two peers: `galaxy-s25` and `iphone-17`. The bee forms a group. Alice
+is the Group Owner because her phone has the better battery.
+
+Bob's phone joins. Carla's phone joins. Three phones on a P2P TCP mesh
+under a single tree, no packets leaving the park.
+
+Alice doesn't notice any of this. She's reading a zine about
+fermentation.
+
+## The first bloom
+
+Bob has been stuck on a recipe for sourdough starter that calls for
+"one cup of chaos agent." He opens his hum CLI and types:
+
+```
+hum ask alice-phone "what does 'one cup of chaos agent' mean in a
+sourdough context? also the starter is runny, should i add flour"
+```
+
+The prompt leaves Bob's phone as a thrum tone, hits his local humd,
+which checks the mesh: Bob's phone has no worker that serves
+sourdough expertise. But Alice's phone advertised `claude-cli` as a
+worker hive in its hello. The ensemble routes the prompt to Alice's
+humd over the P2P TCP link.
+
+Alice's humd spawns a cell. The cell runs Claude. Claude replies:
+
+```
+"Chaos agent" in sourdough means the wild yeast you're cultivating —
+it's unpredictable by design. If your starter is runny, add 10% more
+flour by weight. You're fine.
+```
+
+Chunks stream back across the picnic table, over the P2P link, into
+Bob's phone. The finish tone lands. Bob adds flour. His starter
+thickens. He smiles.
+
+Alice never knew any of this happened. Her phone thought it was idle.
+
+## Carla needs a lift
+
+Carla's iPhone runs humd via iSH (iOS terminal emulator) with a
+wifi-p2p bee that connects as a client. Her phone has no local LLM —
+she's using the openai-server forager pointed at her OpenAI API key.
+But the API key is rate-limited and she's already hit the tier-1 cap.
+
+Her forager advertises `["openai-server"]` in its provides. Bob's humd
+sees this and marks Carla as a route for OpenAI-shaped prompts. When
+Alice's phone needs an embedding for a retrieval task, the prompt
+routes: Alice's humd → P2P TCP → Carla's phone → OpenAI API → reply
+bundle → P2P TCP → Alice. Carla's phone acts as a paid-oracle relay
+without Carla noticing. Her battery drains 2% faster. She thinks it's
+the TikTok tab.
+
+## The table becomes a nest
+
+By hour two, the three phones have gossiped enough state that the
+mesh acts as a single distributed humd. A prompt typed on any phone
+finds compute on any other phone, or reaches OpenAI through Carla's
+forager, or forks a tool-call to Bob's phone for filesystem access.
+
+The picnic table has no power and no internet. It is a wooden surface
+with birdshit on it. But the hum on top of it is a three-node ensemble
+with routing, capability discovery, and session continuity.
+
+When a fourth person sits down — a girl with a Nokia 3310 and no humd
+at all — Alice's phone scans, finds nothing, and keeps scanning. The
+mesh doesn't extend to her. She eats her sandwich and leaves. The hum
+doesn't notice.
+
+## Dispersal
+
+At 4pm, Carla stands up and walks toward the subway. Her phone
+exits the P2P group gracefully — the GO detects the link drop,
+emits `chi:"error"` with `lost` reason, and the remaining two phones
+re-form the group without her. Bob's starter question is already
+finished; no session is orphaned.
+
+Alice's phone has been the GO for two hours. Its battery is at 18%.
+When she finally gets up and walks home, the P2P group dissolves. No
+state is lost — each humd has its own nest, and the ensemble gossip
+washed every tone across all three phones. Alice's phone holds a
+complete copy of Bob's sourdough conversation, Bob's phone holds a
+copy of Carla's OpenAI relay logs, and none of them ever talked to a
+server.
+
+## What this setup looks like
+
+On each phone, the hum setup is:
+
+```json
+{
+ "hum": {
+ "hives": ["claude-cli", "wifi-p2p"],
+ "ensemble": {
+ "discovery": "wifi-p2p",
+ "transport": "p2p-tcp"
+ }
+ }
+}
+```
+
+Three binaries running:
+
+```
+Termux:
+ humd — the daemon, one per phone
+ claude-cli-worker — local inference (Alice, Bob)
+ openai-server — cloud relay (Carla)
+
+Android foreground service:
+ wifi-p2p-bee — P2P discovery + group + TCP bridge
+ — connects to local humd via Unix socket
+ — translates P2P NDJSON ⇄ thrum NDJSON
+```
+
+No config. No `peers.json`. No DNS. The phones find each other by
+WiFi Direct service scan, form a group, and the ensemble layer
+discovers capabilities by gossip. The bee is the radio bridge; the
+rest is stock hum.
+
+## Enjoyment
+
+Bob's starter works. Alice's battery is low but her zine is good.
+Carla's API key survives the afternoon. None of them configured
+anything. The hum found the mesh by walking around.
+
+This is the use case: **three strangers at one table, no cloud, no
+setup, no intentional cooperation, and the conversation still routes
+through the best compute the mesh can find.** The bees do the
+foraging; the humans just sit in the sun.