tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Sutichaykuna

consola: modo supervisor — avisa cuando un Claude te necesita

El foreground service abre un WS de larga vida a /ws/events y, ante cada
evento de hook (Notification/Stop), levanta una notificación de alta
prioridad (canal 'avisos de Claude'). Reconecta solo y sobrevive a la app
en segundo plano. GatewayClient.openEvents + EventsConnection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

f973f9c17d8600ee7965557334798f9fee35be5c · ruwaq Sergio · (4 killa ñawpaq) · tayta e8c6f0224 · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

3 qillqakuna · +210 −5

diff --git a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
index 3da3969..d915e3e 100644
--- a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
+++ b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
@@ -68,6 +68,7 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
             service = svc
             // Si quedó un terminal vivo (app reabierta), restáuralo.
             if (svc.terminal.live) screen = Screen.Terminal
+            ensureSupervising()
             pendingOpen?.let { it(); pendingOpen = null }
         }
 
@@ -84,6 +85,7 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
                 config = it
                 listClient.config = it
                 service?.updateConfig(it)
+                ensureSupervising()
             }
         }
         refresh()
@@ -173,6 +175,19 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
         if (svc != null) open(svc) else pendingOpen = { service?.let(open) }
     }
 
+    /**
+     * Arranca el supervisor (WS de eventos + notificaciones) en cuanto hay una
+     * URL configurada y el servicio está enlazado. Lo promueve a primer plano
+     * para que siga recibiendo avisos con la app cerrada. Idempotente.
+     */
+    private fun ensureSupervising() {
+        val svc = service ?: return
+        if (config.baseUrl.isBlank()) return
+        val app = getApplication<Application>()
+        ContextCompat.startForegroundService(app, Intent(app, ConsolaService::class.java))
+        svc.startSupervising()
+    }
+
     fun onTerminalSized(rows: Int, cols: Int) { service?.onSized(rows, cols) }
 
     fun sendText(text: String) { service?.sendText(text) }
diff --git a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
index 7281646..15c0898 100644
--- a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
+++ b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
@@ -207,11 +207,74 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
         return PtyConnection(ws, gate)
     }
 
+    /**
+     * Abre el WS del **bus de eventos de supervisión** (`/ws/events`): recibe
+     * los eventos que publican los hooks de Claude Code (Notification/Stop) en
+     * el daemon. No manda mensaje de apertura; solo escucha texto. Conexión de
+     * larga vida (la sostiene el servicio de primer plano).
+     */
+    fun openEvents(listener: EventsListener): EventsConnection {
+        var url = config.baseUrl.trimEnd('/') + "/ws/events"
+        url = when {
+            url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
+            url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
+            else -> url
+        }
+        if (config.token.isNotBlank()) {
+            url += "?token=" + URLEncoder.encode(config.token, "UTF-8")
+        }
+        val safeUrl = url.substringBefore('?')
+        DebugLog.log("EVT", "abriendo $safeUrl  (token ${if (config.token.isBlank()) "ausente" else "presente"})")
+        val req = Request.Builder()
+            .url(url)
+            .apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
+            .build()
+        val ws = client.newWebSocket(req, object : WebSocketListener() {
+            override fun onOpen(webSocket: WebSocket, response: Response) {
+                DebugLog.log("EVT", "conectado (HTTP ${response.code})")
+                listener.onOpen()
+            }
+
+            override fun onMessage(webSocket: WebSocket, text: String) {
+                DebugLog.log("EVT", "← ${text.take(160)}")
+                listener.onEvent(text)
+            }
+
+            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+                webSocket.close(1000, null)
+            }
+
+            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+                listener.onClosed(reason.ifBlank { null })
+            }
+
+            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+                DebugLog.log("EVT", "FALLO ${t.javaClass.simpleName}: ${t.message ?: "(sin mensaje)"}")
+                listener.onClosed(t.message)
+            }
+        })
+        return EventsConnection(ws)
+    }
+
     private companion object {
         const val TAG = "ConsolaWS"
     }
 }
 
+/** Eventos del bus de supervisión (hooks de Claude Code → consola). */
+interface EventsListener {
+    fun onOpen() {}
+    fun onEvent(json: String) {}
+    fun onClosed(reason: String?) {}
+}
+
+/** Handle del WS de eventos; cerrarlo desuscribe del bus. */
+class EventsConnection(private val ws: WebSocket) {
+    fun close() {
+        ws.close(1000, null)
+    }
+}
+
 /**
  * Compuerta que garantiza que el `open` salga antes que cualquier `resize`.
  * Hasta que [markOpened] confirme que se envió el open, los resize se guardan
diff --git a/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
index 6b04955..c0f9bec 100644
--- a/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
+++ b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
@@ -18,6 +18,8 @@ import androidx.core.app.ServiceCompat
 import com.rizoma.consola.MainActivity
 import com.rizoma.consola.R
 import com.rizoma.consola.TermStatus
+import com.rizoma.consola.data.EventsConnection
+import com.rizoma.consola.data.EventsListener
 import com.rizoma.consola.data.GatewayClient
 import com.rizoma.consola.data.GatewayConfig
 import com.rizoma.consola.data.PtyConnection
@@ -30,8 +32,10 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import org.json.JSONObject
 
 /**
  * Estado del terminal activo, observable por Compose. Vive en el servicio
@@ -90,11 +94,23 @@ class ConsolaService : Service() {
     private var sizedRows = 24
     private var sizedCols = 80
 
+    // Supervisor: WS del bus de eventos (hooks de Claude → notificaciones).
+    private var eventsConn: EventsConnection? = null
+    private var supervising = false
+    private var reconnectJob: Job? = null
+    private var alertId = ALERT_ID_BASE
+
     override fun onBind(intent: Intent?): IBinder = binder
 
     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
         if (intent?.action == ACTION_STOP) {
-            detachTerminal()
+            // "detener" en la notificación = dejar de vigilar y soltar todo.
+            supervising = false
+            reconnectJob?.cancel(); reconnectJob = null
+            eventsConn?.close(); eventsConn = null
+            teardownTerminal()
+            terminal.live = false
+            ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
             stopSelf()
         }
         // No resucitamos un servicio vacío: el daemon ya conserva la sesión.
@@ -106,6 +122,101 @@ class ConsolaService : Service() {
         client.config = config
     }
 
+    // ---- Supervisor de Claudes (bus de eventos) -----------------------
+
+    /**
+     * Empieza a **vigilar**: abre el WS de `/ws/events` y queda en primer plano
+     * para recibir, aun con la app cerrada, los avisos de los hooks de Claude
+     * Code (pidió permiso / terminó) y levantarlos como notificación.
+     */
+    fun startSupervising() {
+        if (supervising) return
+        supervising = true
+        ensureChannel()
+        ensureAlertChannel()
+        enterForeground(SUPERVISOR_SUBTITLE)
+        connectEvents()
+    }
+
+    fun stopSupervising() {
+        supervising = false
+        reconnectJob?.cancel(); reconnectJob = null
+        eventsConn?.close(); eventsConn = null
+        if (!terminal.live) {
+            ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+            stopSelf()
+        }
+    }
+
+    private fun connectEvents() {
+        eventsConn?.close()
+        eventsConn = client.openEvents(object : EventsListener {
+            override fun onEvent(json: String) { handleEvent(json) }
+            override fun onClosed(reason: String?) {
+                // Reintento mientras sigamos vigilando (NAT/proxy/doze cortan).
+                if (!supervising) return
+                reconnectJob?.cancel()
+                reconnectJob = scope.launch {
+                    delay(3000)
+                    if (supervising) connectEvents()
+                }
+            }
+        })
+    }
+
+    private fun handleEvent(jsonText: String) {
+        val o = runCatching { JSONObject(jsonText) }.getOrNull() ?: return
+        val kind = o.optString("kind")
+        val cwd = o.optString("cwd")
+        val msg = o.optString("message")
+        val dir = cwd.substringAfterLast('/').ifBlank { cwd }
+        val (title, text) = when (kind) {
+            "Notification" ->
+                "Un Claude te necesita" to (msg.ifBlank { "esperando tu input" } + if (dir.isNotBlank()) "  ·  $dir" else "")
+            "Stop" ->
+                "Un Claude terminó" to (if (dir.isNotBlank()) "en $dir" else "tarea completada")
+            else ->
+                kind.ifBlank { "Claude" } to msg.ifBlank { dir }
+        }
+        notifyAlert(title, text)
+    }
+
+    private fun notifyAlert(title: String, text: String) {
+        val mgr = getSystemService(NotificationManager::class.java)
+        val open = PendingIntent.getActivity(
+            this, 2,
+            Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+            PendingIntent.FLAG_IMMUTABLE,
+        )
+        val n = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
+            .setSmallIcon(R.drawable.ic_consola)
+            .setContentTitle(title)
+            .setContentText(text)
+            .setColor(NEON_VERDE)
+            .setPriority(NotificationCompat.PRIORITY_HIGH)
+            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+            .setAutoCancel(true)
+            .setContentIntent(open)
+            .build()
+        // ids rotativos para que no se pisen los avisos consecutivos.
+        mgr.notify(alertId++, n)
+        if (alertId > ALERT_ID_BASE + 50) alertId = ALERT_ID_BASE
+    }
+
+    private fun ensureAlertChannel() {
+        val mgr = getSystemService(NotificationManager::class.java)
+        if (mgr.getNotificationChannel(ALERT_CHANNEL_ID) == null) {
+            val ch = NotificationChannel(
+                ALERT_CHANNEL_ID,
+                "avisos de Claude",
+                NotificationManager.IMPORTANCE_HIGH,
+            ).apply {
+                description = "cuando un Claude pide permiso o termina una tarea"
+            }
+            mgr.createNotificationChannel(ch)
+        }
+    }
+
     // ---- Terminal -----------------------------------------------------
 
     /**
@@ -203,9 +314,17 @@ class ConsolaService : Service() {
         leaveForeground()
     }
 
-    /** Abandona el primer plano y quita la notificación (hilo principal). */
+    /**
+     * El terminal dejó el primer plano. Si seguimos vigilando, NO salimos del
+     * primer plano: re-asentamos la notificación de supervisor. Si no, la
+     * quitamos.
+     */
     private fun leaveForeground() {
-        ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+        if (supervising) {
+            enterForeground(SUPERVISOR_SUBTITLE)
+        } else {
+            ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+        }
     }
 
     /** El render midió la rejilla disponible: reescala emulador + PTY. */
@@ -220,12 +339,15 @@ class ConsolaService : Service() {
 
     fun sendBytes(bytes: ByteArray) { connection?.sendInput(bytes) }
 
-    /** Cierra el WS (DETACH; la sesión sigue en el daemon) y abandona el primer plano. */
+    /**
+     * Cierra el WS del terminal (DETACH; la sesión sigue en el daemon). Si
+     * estamos vigilando, el servicio sigue vivo como supervisor; si no, baja.
+     */
     fun detachTerminal() {
         teardownTerminal()
         terminal.live = false
         leaveForeground()
-        stopSelf()
+        if (!supervising) stopSelf()
     }
 
     private fun teardownTerminal() {
@@ -239,6 +361,8 @@ class ConsolaService : Service() {
 
     override fun onDestroy() {
         teardownTerminal()
+        reconnectJob?.cancel()
+        eventsConn?.close()
         scope.cancel()
         super.onDestroy()
     }
@@ -300,8 +424,11 @@ class ConsolaService : Service() {
 
     companion object {
         private const val CHANNEL_ID = "consola_sesiones"
+        private const val ALERT_CHANNEL_ID = "consola_alertas"
         private const val NOTIF_ID = 0xC0_50 // "co so" → consola
+        private const val ALERT_ID_BASE = 0xA1_E0 // base de ids para avisos
         private const val NEON_VERDE = 0xFF7CFF4F.toInt()
+        private const val SUPERVISOR_SUBTITLE = "vigilando tus Claudes"
         const val ACTION_STOP = "com.rizoma.consola.action.STOP"
     }
 }