feat(#532): add process.list + process.kill manager screen (#556)

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-11 20:17:03 +03:00
1 parent cf893585b1
commit afa82ceec2
10 files changed
+661

No files matched your search

@@ -48,3 +48,5 @@ import kotlinx.serialization.Serializable
@Serializable data object SystemScreen : NavKey
@Serializable data object KanbanScreen : NavKey
@Serializable data object ProcessesScreen : NavKey
@@ -36,6 +36,7 @@ import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent
import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
import com.m57.hermescontrol.ui.sessions.SessionsScreen as HistoryScreenContent
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
@@ -166,6 +167,12 @@ object ScreenRegistry {
Icons.Filled.HistoryEdu,
DrawerSection.INSPECT,
) { sessionId, openDrawer -> LogsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
ProcessesScreen,
R.string.screen_processes,
Icons.Filled.Memory,
DrawerSection.INSPECT,
) { sessionId, openDrawer -> ProcessesScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
KanbanScreen,
R.string.screen_kanban,
@@ -0,0 +1,50 @@
package com.m57.hermescontrol.data.model
/**
* A single background process owned by a session, as returned by the gateway
* `process.list` RPC.
*
* Field names match the gateway JSON exactly (Gson deserializes by name,
* snake_case). See `tui_gateway/server.py::_session_processes` and
* `tools/process_registry.list_sessions` for the canonical shape.
*
* @property sessionId Stable process id used by `process.kill` as `process_id`.
* @property command First 200 chars of the spawned command line.
* @property cwd Working directory the process was launched in.
* @property pid OS process id (0 / null for detached or not-yet-spawned).
* @property startedAt ISO-8601 local timestamp string from the backend.
* @property uptimeSeconds Seconds since spawn (backend-computed).
* @property status `"running"` or `"exited"`.
* @property outputPreview Last 200 chars of buffered output.
* @property outputTail Last 4000 chars of buffered output (richer preview).
* @property exitCode Present only when [status] == `"exited"`.
* @property sessionScoped True when surfaced only because it shares the gateway
* session (not the current task) — a possibly-forgotten long-lived process.
*/
data class ProcessInfo(
val sessionId: String,
val command: String? = null,
val cwd: String? = null,
val pid: Int? = null,
val startedAt: String? = null,
val uptimeSeconds: Int? = null,
val status: String? = null,
val outputPreview: String? = null,
val outputTail: String? = null,
val exitCode: Int? = null,
val sessionScoped: Boolean = false,
) {
val isRunning: Boolean get() = status != "exited"
/** A short, human-readable title derived from the command line. */
val title: String
get() =
(command ?: "")
.lineSequence()
.firstOrNull()
?.trim()
.orEmpty()
.ifEmpty { "background process" }
companion object
}
@@ -0,0 +1,28 @@
package com.m57.hermescontrol.data.session
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* App-wide mirror of the active chat session id.
*
* The desktop `process.list` / `process.kill` RPCs are session-scoped — the
* gateway returns `4001 "session not found"` without a valid `session_id`, and
* mobile has no global "current session" holder (ChatViewModel keeps it
* privately). This singleton holds the last-known active session id so
* session-scoped drawer screens (e.g. the Processes screen, issue #532) can
* issue those RPCs. ChatViewModel writes here on every switch/resume; it is a
* best-effort mirror and may be null when no chat session has been opened yet.
*/
object ActiveSessionHolder {
private val _activeSessionId = MutableStateFlow<String?>(null)
/** The currently active chat session id, or null if none is known yet. */
val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow()
/** Update the active session id. Pass null to clear (e.g. on logout). */
fun set(sessionId: String?) {
_activeSessionId.value = sessionId
}
}
@@ -32,4 +32,12 @@ object WsMethods {
/** Stage a non-image file (data URL) for agent access. */
const val FILE_ATTACH = "file.attach"
// ── Background process manager (issue #532) ──────────────────────────
/** List background processes owned by the active session. */
const val PROCESS_LIST = "process.list"
/** Kill a single background process (scoped to the active session). */
const val PROCESS_KILL = "process.kill"
}
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -414,6 +415,10 @@ class ChatViewModel(
chatTitle = "Hermes",
)
}
// Mirror the active session id app-wide so session-scoped
// drawer screens (e.g. Processes, issue #532) can issue
// session-scoped RPCs. See ActiveSessionHolder.
ActiveSessionHolder.set(sessionId)
_streamingState.update { StreamingState() }
addSystemMessage("Session created", persist = true)
loadSessions()
@@ -456,6 +461,8 @@ class ChatViewModel(
currentSessionId = sessionId,
)
}
// Mirror the active session id app-wide (issue #532).
ActiveSessionHolder.set(sessionId)
addSystemMessage("Session resumed")
}
@@ -891,6 +898,8 @@ class ChatViewModel(
isAgentTyping = false,
)
}
// Mirror the active session id app-wide (issue #532).
ActiveSessionHolder.set(sessionId)
_streamingState.update { StreamingState() }
viewModelScope.launch {
// Step 1: Load cached messages first — instant display
@@ -0,0 +1,254 @@
package com.m57.hermescontrol.ui.process
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ProcessInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@Composable
fun ProcessesScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ProcessesViewModel = viewModel { ProcessesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var killTarget by remember { mutableStateOf<ProcessInfo?>(null) }
LaunchedEffect(Unit) {
if (state.sessionId != null) viewModel.load()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_processes)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
modifier = modifier,
) {
when {
state.sessionId == null ->
EmptyState(
title = stringResource(R.string.processes_no_session_title),
subtitle = stringResource(R.string.processes_no_session_desc),
icon = Icons.Filled.Memory,
)
state.isLoading && state.processes.isEmpty() ->
LoadingState()
state.errorMessage != null ->
ErrorState(
message = state.errorMessage ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.load() },
)
state.processes.isEmpty() ->
EmptyState(
title = stringResource(R.string.processes_empty_title),
subtitle = stringResource(R.string.processes_empty_desc),
icon = Icons.Filled.Memory,
)
else ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.processes, key = { it.sessionId }) { process ->
ProcessCard(
process = process,
isKilling = state.killingId == process.sessionId,
onKill = { killTarget = process },
)
}
}
}
}
killTarget?.let { process ->
AlertDialog(
onDismissRequest = { killTarget = null },
title = { Text(stringResource(R.string.processes_kill_title)) },
text = {
Text(
stringResource(
R.string.processes_kill_desc,
process.title,
),
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.kill(process.sessionId)
killTarget = null
},
) { Text(stringResource(R.string.processes_kill_confirm)) }
},
dismissButton = {
TextButton(onClick = { killTarget = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun ProcessCard(
process: ProcessInfo,
isKilling: Boolean,
onKill: () -> Unit,
) {
val statusColors = LocalHermesStatusColors.current
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = process.statusIcon(),
contentDescription = null,
tint = process.statusColor(statusColors),
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = process.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = process.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (process.isRunning) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.processes_running_hint),
style = MaterialTheme.typography.labelSmall,
color = statusColors.success,
)
}
}
if (process.isRunning) {
Spacer(modifier = Modifier.width(8.dp))
if (isKilling) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(
onClick = onKill,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.processes_kill_cd),
tint = statusColors.error,
)
}
}
}
}
}
}
private val ProcessInfo.subtitle: String
get() {
val parts = mutableListOf<String>()
pid?.let { parts += "pid $it" }
uptimeSeconds?.let { parts += formatUptime(it) }
if (!isRunning) {
exitCode?.let { parts += "exit $it" }
}
cwd?.let { parts += it }
return parts.joinToString(" · ")
}
private fun formatUptime(seconds: Int): String {
val m = seconds / 60
val s = seconds % 60
return when {
m <= 0 -> "${s}s"
m < 60 -> "${m}m ${s}s"
else -> {
val h = m / 60
"${h}h ${m % 60}m"
}
}
}
private fun ProcessInfo.statusIcon(): ImageVector = if (isRunning) Icons.Filled.PlayArrow else Icons.Filled.Memory
@Composable
private fun ProcessInfo.statusColor(statusColors: com.m57.hermescontrol.theme.HermesStatusColors): Color =
if (isRunning) statusColors.success else statusColors.info
@@ -0,0 +1,168 @@
package com.m57.hermescontrol.ui.process
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ProcessInfo
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ProcessesUiState(
val isLoading: Boolean = false,
val processes: List<ProcessInfo> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
/** The process id currently being killed (drives the in-row spinner). */
val killingId: String? = null,
/** The active session id the list is scoped to (null = no chat session yet). */
val sessionId: String? = null,
)
class ProcessesViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProcessesUiState())
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
init {
// Keep the session id in sync and auto-load whenever it becomes known.
viewModelScope.launch {
ActiveSessionHolder.activeSessionId.collect { sid ->
_uiState.update { it.copy(sessionId = sid) }
if (sid != null) {
load()
} else {
_uiState.update {
it.copy(
processes = emptyList(),
errorMessage = null,
)
}
}
}
}
}
/** Pull the active session's process snapshot from the gateway. */
fun load() {
val sessionId =
ActiveSessionHolder.activeSessionId.value
?: run {
_uiState.update {
it.copy(
isLoading = false,
processes = emptyList(),
errorMessage = "Open a chat session first — process list is session-scoped.",
)
}
return
}
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val result =
HermesWsClient
.request(
WsMethods.PROCESS_LIST,
mapOf("session_id" to sessionId),
).await()
val processes = parseProcessList(result)
_uiState.update {
it.copy(
isLoading = false,
processes = processes,
errorMessage = null,
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Could not load processes: ${e.message ?: e.javaClass.simpleName}",
)
}
}
}
}
/** Kill a single process by id, then refresh the list. */
fun kill(processId: String) {
val sessionId =
ActiveSessionHolder.activeSessionId.value
?: run {
_uiState.update { it.copy(toastMessage = "No active session — cannot kill.") }
return
}
if (_uiState.value.killingId != null) return // one kill at a time
_uiState.update { it.copy(killingId = processId) }
viewModelScope.launch {
try {
HermesWsClient
.request(
WsMethods.PROCESS_KILL,
mapOf(
"process_id" to processId,
"session_id" to sessionId,
),
).await()
_uiState.update { it.copy(killingId = null, toastMessage = "Process killed") }
load()
} catch (e: Exception) {
_uiState.update {
it.copy(
killingId = null,
toastMessage = "Kill failed: ${e.message ?: e.javaClass.simpleName}",
)
}
}
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
/**
* Parse the `process.list` RPC result (`{ "processes": [ {…} ] }`) into
* [ProcessInfo]s. The result is a `kotlinx` `toAny()` map (numbers arrive as
* Double/Long/Int, booleans as Boolean) — see [HermesWsClient].
*/
private fun parseProcessList(result: Any?): List<ProcessInfo> {
val map = result as? Map<*, *> ?: return emptyList()
val rawList = map["processes"] as? List<*> ?: return emptyList()
return rawList.mapNotNull { entry ->
val proc = entry as? Map<*, *> ?: return@mapNotNull null
ProcessInfo.fromMap(proc)
}
}
}
private fun Map<*, *>.str(key: String): String? = (this[key] as? String)?.takeIf { it.isNotBlank() }
private fun Map<*, *>.intOrNull(key: String): Int? = (this[key] as? Number)?.toInt()
private fun Map<*, *>.bool(key: String): Boolean = (this[key] as? Boolean) ?: false
/** Build a [ProcessInfo] from a raw `process.list` entry map. */
fun ProcessInfo.Companion.fromMap(raw: Map<*, *>): ProcessInfo? {
val m = raw.mapKeys { it.key.toString() }
val sessionId = m.str("session_id") ?: return null
return ProcessInfo(
sessionId = sessionId,
command = m.str("command"),
cwd = m.str("cwd"),
pid = m.intOrNull("pid"),
startedAt = m.str("started_at"),
uptimeSeconds = m.intOrNull("uptime_seconds"),
status = m.str("status"),
outputPreview = m.str("output_preview"),
outputTail = m.str("output_tail"),
exitCode = m.intOrNull("exit_code"),
sessionScoped = m.bool("session_scoped"),
)
}