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>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
3 archivos · +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"
}
}