diff --git a/README.md b/README.md index 0e41c880..8a8512e7 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Words in the chats are looked for on the machines themselves: each running agent **Start work** asks first what you want, then how and where. It opens from the Tasks tab's **+** (**New task** or **Start an agent**), a space's long-press sheet in the drawer, the search, and the launcher's shortcut. - **Board task**: the sentence goes to the chosen space's Assistant (**Send to the Assistant**), picked among the machine's spaces, which makes the task: Shepherdr makes tasks only through a space's Assistant. -- **Agent now**: an agent starts in the folder, in a new tab of the workspace that sits there (else a workspace of its own), named after the sentence's first words, and the sentence is its first message. The agents offered are the ones installed on that machine: those herdr's integrations find, and every other kind herdr can start whose program is on the machine's PATH (gemini, amp…). +- **Agent now**: an agent starts in the folder, in a new tab of the workspace that sits there (else a workspace of its own), named after the sentence's first words, and the sentence is its first message. The agents offered are the ones installed on that machine: those herdr's integrations find, and every other kind herdr can start whose program is on the machine's PATH (gemini, amp…). On a machine that runs only the Herdr desktop app (herdr-core, no classic herdr), it starts with `hc run` instead: in the desktop app's space that sits in the folder, as a tab of its own (else under **Other terminals**), its chat opens at once with the sentence held above the box, and it goes in once the agent says it waits for it (where one says nothing, or asks something first, it stays held for you to send, and a toast says so). The folders offered are the ones work was started in from the phone, then the workspaces' (a task's worktree counts as its repository); **Browse…** walks the machine's folders, Git repositories marked. The phone never starts an agent in a mode that skips its permission prompts: it asks before it acts, as it does at the desk. @@ -130,7 +130,9 @@ The folders offered are the ones work was started in from the phone, then the wo A machine's screen (a tap on the machine in the spaces drawer) opens with its places as tiles in one row, each with a live line: **Health**, **Usage** and **Logins**, framed in a state colour only when something there is wrong. Its spaces follow (classic herdr's workspaces and herdr-core's spaces alike), each with its branch and pull request under its name and one row per pane: the agent and its state, or the shell and its folder, a tap opening its chat (or terminal) and the terminal icon its terminal. Tab chips show only for a workspace with several tabs. The bar says when the machine last answered ("Online · answered 4 s ago"). -Everything that exists in a classic herdr session is editable from the app, not just readable (herdr-core's spaces are the desktop app's: their panes open, nothing more). A workspace or tab header has an actions button (a workspace's one tab is under its actions, **Its tab**), and panes and agents answer to a long press: rename, focus, reorder, move, swap, zoom, clear a pane's scrollback, read what a pane is running, and ask `agent.explain` why an agent is in the state it shows. Closing a workspace, tab or pane asks first. herdr's own refusals ("There's no pane in that direction.") come back as plain sentences. +Everything that exists in a classic herdr session is editable from the app, not just readable (herdr-core's spaces are the desktop app's: their terminals open, start and close, see below). A workspace or tab header has an actions button (a workspace's one tab is under its actions, **Its tab**), and panes and agents answer to a long press: rename, focus, reorder, move, swap, zoom, clear a pane's scrollback, read what a pane is running, and ask `agent.explain` why an agent is in the state it shows. Closing a workspace, tab or pane asks first. herdr's own refusals ("There's no pane in that direction.") come back as plain sentences. + +The Herdr desktop app's terminals (herdr-core) have none of classic herdr's layouts, but they start and close from the phone too: a space's **⋯ › New › Terminal** opens a shell in the space's folder as a tab of its own (`hc run`), and **Close terminal** (an agent's or a terminal's **⋯**, or a long press on a terminal on the machine screen) ends it with `hc kill`, asking first. The screens follow herdr's `events.subscribe` stream, so a change made anywhere — desktop or phone — appears here. Notifications are the exception: herdr has no list of them, so the app derives them from agents finishing or blocking (see Notifications below). diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/data/ComposerStore.kt b/app/src/main/kotlin/com/makefunstuff/herdr/data/ComposerStore.kt index 006c54ae..0212ba4d 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/data/ComposerStore.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/data/ComposerStore.kt @@ -32,6 +32,23 @@ class ComposerStore(private val file: File) { return m } + /** + * Claims [id] to send it: takes it out of [key]'s list and says whether it was still there, at once, so two + * senders (Send now, and Start work's first message) never both send it. A failed send puts it back ([restore]). + */ + @Synchronized + fun take(key: String, id: String): HeldMessage? { + val m = held(key).firstOrNull { it.id == id } ?: return null + drop(key, id) + return m + } + + /** Puts [m] back in [key]'s list, in its place by time: its send failed. */ + fun restore(key: String, m: HeldMessage) = change { s -> + val list = s.held[key].orEmpty().filterNot { it.id == m.id } + s.copy(held = s.held + (key to (list + m).sortedBy { it.at })) + } + /** Takes [id] out of [key]'s list: sent, or discarded. */ fun drop(key: String, id: String) = change { s -> val left = s.held[key].orEmpty().filterNot { it.id == id } diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/agents/AgentScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/agents/AgentScreen.kt index ae9a62ce..9f3b82cc 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/agents/AgentScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/agents/AgentScreen.kt @@ -415,14 +415,16 @@ fun AgentScreen(nav: NavController, machineId: String, paneId: String) { /** A held message, now: sent as typed then (Claude Code and Codex take words mid-turn as their next). */ fun sendHeld(m: HeldMessage) { if (heldSending || sending || !Held.canSend(status, false)) return + // Claimed first: Start work's first message on herdr-core may be going by itself at this moment. + val claimed = container.composer.take(chatKey, m.id) ?: return heldSending = true awayHidden = true chat.addPending(m.text) stick.sent() scope.launch { runCatching { AgentInput.send(session.awaitAgent(paneId), paneId, m.text, named = agent != null, blocked = false) } - .onSuccess { container.composer.drop(chatKey, m.id) } .onFailure { + container.composer.restore(chatKey, claimed) chat.dropPending(m.text) context.toast(it.message ?: "Couldn't send") } @@ -654,6 +656,8 @@ fun AgentScreen(nav: NavController, machineId: String, paneId: String) { paneLabel = pane?.displayName ?: paneId, onDismiss = { menu = false }, screenMenu = true, + // Back from the chat, unless the person has gone elsewhere while it closed. + onClosed = if (inBubble) null else nav.currentBackStackEntry.let { entry -> { if (nav.currentBackStackEntry == entry) nav.popBackStack() } }, tabRows = tab?.takeIf { !inBubble }?.let { (space, t) -> { TabMenuRows(tabMenu, machineId, space, t, onPicked = { menu = false }) { label, icon, destructive, onClick -> diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/MachineScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/MachineScreen.kt index 96bde53d..1884f263 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/MachineScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/MachineScreen.kt @@ -81,6 +81,7 @@ import com.makefunstuff.herdr.ui.design.QuietCard import com.makefunstuff.herdr.ui.manage.PaneActionsSheet import com.makefunstuff.herdr.ui.manage.TabActionsSheet import com.makefunstuff.herdr.ui.manage.WorkspaceActionsSheet +import com.makefunstuff.herdr.ui.manage.shepherdrsSpace import com.makefunstuff.herdr.ui.theme.Herdr import com.makefunstuff.herdr.ui.theme.statusWord import com.makefunstuff.herdr.ui.theme.Space @@ -110,6 +111,7 @@ fun MachineScreen(nav: NavController, machineId: String) { var workspaceMenu by remember { mutableStateOf(null) } var tabMenu by remember { mutableStateOf(null) } var paneMenu by remember { mutableStateOf(null) } + var coreClose by remember { mutableStateOf(null) } val selectedTab = remember { mutableStateMapOf() } fun openPane(pane: PaneInfo) { @@ -181,7 +183,7 @@ fun MachineScreen(nav: NavController, machineId: String) { onTabMenu = { tabMenu = it }, onPaneMenu = { paneMenu = it }, ) else WorkspaceCard( - // herdr-core has no layouts or actions on its spaces: the panes open, nothing more. + // herdr-core has no layouts or actions on its spaces: the panes open, and close (`hc kill`) on a long press. ws, from, meta = emptyMap(), @@ -190,11 +192,16 @@ fun MachineScreen(nav: NavController, machineId: String) { onOpenPane = ::openPane, onTerminal = { nav.navigate(Routes.terminal(machineId, it.paneId)) }, backend = if (both) "herdr-core ${core?.version.orEmpty()}".trim() else null, + // A space of Shepherdr's (its Assistant's, its tasks' workers') closes as Shepherdr closes them, not here. + onPaneMenu = { p: PaneInfo -> coreClose = p }.takeIf { !from.shepherdrsSpace(ws.workspaceId) }, ) } } } + coreClose?.let { pane -> + com.makefunstuff.herdr.ui.manage.CloseHcTerminalDialog(session, pane.paneId, pane.displayName, onDismiss = { coreClose = null }) + } snap?.let { s -> workspaceMenu?.let { ws -> val folder = ws.worktree?.checkoutPath?.ifBlank { null } ?: s.workspaceFolder(ws.workspaceId) diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/manage/ManageSheets.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/manage/ManageSheets.kt index 891163b3..e6aab89e 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/manage/ManageSheets.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/manage/ManageSheets.kt @@ -75,6 +75,8 @@ import com.makefunstuff.herdr.ui.common.copyToClipboard import com.makefunstuff.herdr.ui.common.toast import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers /** * Runs one management call and tells the person what happened: herdr's own words when it refuses @@ -96,6 +98,53 @@ fun manage( } } +/** + * Ends a herdr-core terminal ([paneId], `hc:t7`) as `hc kill` does: hangup, then terminate, then kill. It runs on + * [scope], the app's, so a sheet or screen closing meanwhile doesn't call it off; [onDone] (on the main thread) gets + * whether it went. Its core then drops it from the lists; a refusal is said in a toast. + */ +fun closeHcTerminal(scope: CoroutineScope, session: HostSession, paneId: String, onDone: (Boolean) -> Unit) { + val address = HcMapping.address(paneId) ?: return + scope.launch { + val failed = try { + session.awaitHerdrCore(10_000, paneId).kill(address) + null + } catch (e: java.io.IOException) { + e.message ?: "herdr-core didn't answer" + } + withContext(Dispatchers.Main) { + failed?.let { com.makefunstuff.herdr.ui.design.Toasts.show(it) } + onDone(failed == null) + } + } +} + +/** + * A space of Shepherdr's (one holding its Assistant or a task's worker): its herdr-core terminals close as Shepherdr + * closes them, in its tab menu, not with Close terminal. One rule for the agent's ⋯, the terminal's and the machine screen. + */ +fun SessionSnapshot?.shepherdrsSpace(workspaceId: String?): Boolean = + this != null && workspaceId != null && agents.any { it.workspaceId == workspaceId && (it.shepherdrTask != null || it.shepherdrSpace != null) } + +/** [paneId] is in a space of Shepherdr's ([shepherdrsSpace]). */ +fun SessionSnapshot?.shepherdrsPane(paneId: String): Boolean = + shepherdrsSpace(this?.let { s -> s.panes.firstOrNull { it.paneId == paneId }?.workspaceId ?: s.agents.firstOrNull { it.paneId == paneId }?.workspaceId }) + +/** Asks before a herdr-core terminal is closed, as the classic Close pane does; [onClosed] once it is. */ +@Composable +fun CloseHcTerminalDialog(session: HostSession, paneId: String, label: String, onDismiss: () -> Unit, onClosed: () -> Unit = {}) { + val app = com.makefunstuff.herdr.ui.common.LocalContainer.current.scope + ConfirmDialog( + "Close $label?", + "Its program ends and the terminal closes, on the desktop too.", + "Close terminal", + onDismiss = onDismiss, + ) { + onDismiss() + closeHcTerminal(app, session, paneId) { if (it) onClosed() } + } +} + /** The shell every management sheet sits in: a title, a one-line subtitle, and a scrolling list. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -551,6 +600,8 @@ fun AgentActionsSheet( tabRows: (@Composable () -> Unit)? = null, /** It is a chat's ⋯ menu: "Change this screen" is in it. */ screenMenu: Boolean = false, + /** Its herdr-core terminal was closed (Close terminal): the chat goes back. */ + onClosed: (() -> Unit)? = null, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -558,6 +609,8 @@ fun AgentActionsSheet( var rename by remember { mutableStateOf(false) } var details by remember { mutableStateOf(false) } var explain by remember { mutableStateOf(false) } + var confirmClose by remember { mutableStateOf(false) } + val hostState by session.state.collectAsStateWithLifecycle() val (title, subtitle) = agentTitle(agent, paneLabel, task) ManageSheet(title, subtitle, onDismiss) { @@ -593,6 +646,17 @@ fun AgentActionsSheet( } } } + // herdr-core's terminal closes with `hc kill`; classic herdr's panes close from the machine screen. Shepherdr's own + // agents (a task's worker, a space's Assistant) close as Shepherdr closes them, in its tab menu. + val shepherdrs = tabRows != null || agent?.shepherdrTask != null || agent?.shepherdrSpace != null || hostState.view.shepherdrsPane(target ?: "") + if (target != null && HcMapping.address(target) != null && !shepherdrs) { + ManageHeading("Danger") + ManageRow("Close terminal", detail = "End the agent and its terminal", icon = HerdrIcons.Trash2, destructive = true) { confirmClose = true } + } + } + + if (confirmClose && target != null) { + CloseHcTerminalDialog(session, target, title, onDismiss = { confirmClose = false }, onClosed = { onDismiss(); onClosed?.invoke() }) } if (rename && target != null) { diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/spaces/Spaces.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/spaces/Spaces.kt index 270aea71..fe978778 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/spaces/Spaces.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/spaces/Spaces.kt @@ -61,6 +61,7 @@ import com.makefunstuff.herdr.core.board.WorkOverview import com.makefunstuff.herdr.core.board.WorkSpace import com.makefunstuff.herdr.core.board.WorkTab import com.makefunstuff.herdr.core.hc.HcMapping +import com.makefunstuff.herdr.core.hc.HcStart import com.makefunstuff.herdr.core.host.HostState import com.makefunstuff.herdr.core.push.Notify import com.makefunstuff.herdr.core.usage.UsageRules @@ -444,7 +445,8 @@ fun SpaceSlot.shownTerminal(host: HostState): String? = /** * The + menu's Terminal: a shell as a new tab of a space, then its terminal. Shepherdr opens it in the space's folder - * (`tab-new`); on classic herdr it is a new tab of the space's workspace. herdr-core alone opens no terminal for the phone. + * (`tab-new`); on classic herdr it is a new tab of the space's workspace; on herdr-core (the desktop app) a new terminal + * (`hc run`) in the space's folder, labelled into the space as a tab of its own. */ @Stable class NewTerminal internal constructor( @@ -460,7 +462,10 @@ class NewTerminal internal constructor( /** The space's workspace classic herdr has, where a new tab can open; null on herdr-core alone. */ private fun SpaceSlot.classic(): WorkspaceInfo? = workspaces.firstOrNull { !it.workspaceId.startsWith(HcMapping.PREFIX) } - fun can(slot: SpaceSlot, host: HostState): Boolean = (slot.board != null && host.onShepherdr) || slot.classic() != null + /** The space's herdr-core space, where `hc run` can start a terminal; null where it has none. */ + private fun SpaceSlot.core(): WorkspaceInfo? = workspaces.firstOrNull { it.workspaceId.startsWith(HcMapping.PREFIX) } + + fun can(slot: SpaceSlot, host: HostState): Boolean = (slot.board != null && host.onShepherdr) || slot.classic() != null || slot.core() != null fun open(slot: SpaceSlot, host: HostState) { if (opening) return @@ -477,9 +482,17 @@ class NewTerminal internal constructor( withTimeoutOrNull(10_000) { session.state.map { s -> TabMenus.newShell(before, s.view, slot.name) ?: TabMenus.newShell(before, s.view, board.name) }.filterNotNull().first() } - } else { - val ws = slot.classic() ?: error("herdr-core opens no terminal from the phone") + } else if (slot.classic() != null) { + val ws = slot.classic()!! session.awaitApi(10_000).openShell(ws.workspaceId, "shell", slot.panes.firstOrNull()?.cwd).also { session.refresh() } + } else { + // herdr-core alone: a shell in the space's folder, as a tab of its own; it shows once its core says so. + val ws = slot.core() ?: error("This space has nowhere to open a terminal") + val place = HcStart.place(ws.workspaceId, ws.label.ifBlank { slot.name }, "Terminal") ?: error("This space has nowhere to open a terminal") + val cwd = slot.panes.firstOrNull { it.workspaceId == ws.workspaceId }?.cwd + val pane = HcStart.shell(session.awaitHerdrCore(10_000, ws.workspaceId), place, cwd) + withTimeoutOrNull(10_000) { session.state.map { s -> s.view?.panes?.any { it.paneId == pane } }.first { it == true } } + pane } }.onSuccess { pane -> if (pane != null) opened = slot.machineId to pane else Toasts.show("A terminal opened in ${slot.name}") diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/start/StartWorkScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/start/StartWorkScreen.kt index 02ce6001..108b9fb0 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/start/StartWorkScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/start/StartWorkScreen.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.makefunstuff.herdr.LocalPanes import com.makefunstuff.herdr.Routes +import com.makefunstuff.herdr.core.hc.HcStart import com.makefunstuff.herdr.core.host.HostSession import com.makefunstuff.herdr.core.start.AgentLaunch import com.makefunstuff.herdr.core.start.Folder @@ -65,6 +66,7 @@ import com.makefunstuff.herdr.ui.design.RowDivider import com.makefunstuff.herdr.ui.design.SegmentedPills import com.makefunstuff.herdr.ui.theme.Herdr import com.makefunstuff.herdr.ui.theme.Space +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** @@ -92,7 +94,8 @@ fun StartWorkScreen(nav: NavController, machineId: String? = null, text: String // Where each way can run: a board task on a machine with a board, an agent on any machine online. val online = hosts.filter { it.online && it.machine.enabled } - val able = online.filter { if (board) it.onShepherdr else it.classic } // an agent starts in classic herdr + // An agent starts in classic herdr, else in herdr-core (the desktop app's `hc run`). + val able = online.filter { if (board) it.onShepherdr else it.classic || it.herdrCore != null } val host = able.firstOrNull { it.machine.id == chosen } ?: able.firstOrNull() val session = host?.let { container.hosts.session(it.machine.id) } val mid = host?.machine?.id @@ -106,7 +109,9 @@ fun StartWorkScreen(nav: NavController, machineId: String? = null, text: String var spaceId by rememberSaveable { mutableStateOf(null) } val space = spaces?.let { l -> l.firstOrNull { it.id == spaceId } ?: l.firstOrNull { !it.home } ?: l.firstOrNull() } // The folders used and the workspaces'. - val places = remember(host?.snapshot, starts, mid) { mid?.let { StartWork.recentFolders(host.snapshot, container.starts.folders(it)) }.orEmpty() } + val places = remember(host?.snapshot, host?.herdrCore, starts, mid) { + mid?.let { StartWork.recentFolders(if (host.classic) host.snapshot else host.view, container.starts.folders(it)) }.orEmpty() + } val place = folder?.takeIf { it.isNotEmpty() } ?: places.firstOrNull() // The agents installed there, as herdr's integrations and the machine's programs say; the usual list when it can't say. val kinds by produceState?>(null, mid, board) { @@ -133,15 +138,52 @@ fun StartWorkScreen(nav: NavController, machineId: String? = null, text: String } fun startAgent(s: HostSession, m: String, said: String, k: String, dir: String) { - val snapshot = host?.snapshot + val classic = host?.classic == true + val snapshot = if (classic) host?.snapshot else host?.view busy = true scope.launch { runCatching { val taken = snapshot?.agents?.mapNotNull { it.name }.orEmpty() - AgentLaunch.inFolder(s.awaitApi(10_000), snapshot, dir, k, StartWork.agentName(said, k, taken), said).also { s.refresh() } + val name = StartWork.agentName(said, k, taken) + if (classic) AgentLaunch.inFolder(s.awaitApi(10_000), snapshot, dir, k, name, said).also { s.refresh() } + else { + // herdr-core alone: `hc run` in the folder, in the desktop app's space that sits there (else Other terminals). + val ws = HcStart.spaceIn(snapshot, dir) + val space = ws?.let { id -> snapshot?.workspaces?.firstOrNull { it.workspaceId == id } } + val place = ws?.let { HcStart.place(it, space?.label.orEmpty(), name) } ?: HcStart.Place(null, null, emptyMap()) + val client = s.awaitHerdrCore(10_000, ws) + // A Windows machine (Git Bash) runs hc's lines with cmd.exe: asked once, as the health page asks. + val windows = runCatching { HcStart.windows(s.awaitConnection(10_000).run("uname -s", timeoutSeconds = 10).stdout) }.getOrDefault(false) + val pane = HcStart.launch(client, place, dir, StartWork.STARTABLE[k] ?: k, name, windows) + container.starts.started(m, dir, board = false, startNow = starts.startNow, kind = k) + // The chat opens now, the sentence held above its box (to send or discard by hand meanwhile). It goes + // in once the agent says it waits, on the app's scope so leaving the chat doesn't lose it, and only + // while it is still held there; else it stays held, and a toast says why. + if (said.isNotBlank()) { + val key = com.makefunstuff.herdr.data.DraftStore.agent(m, pane) + val held = container.composer.hold(key, said, container.clock()) + container.scope.launch { + var claimed = false + val why = HcStart.deliver( + client, pane, said, + wanted = { container.composer.held(key).any { it.id == held.id } }, + // Taken off the list as it goes, so Send now can't send it as well. + claim = { (container.composer.take(key, held.id) != null).also { claimed = it } }, + ) + // Claimed and refused: back above the box. + if (why != null && claimed) container.composer.restore(key, held) + if (why != null) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + com.makefunstuff.herdr.ui.design.Toasts.show("What you said didn't go in to $k ($why): it waits above its chat's box") + } + } + } + kotlinx.coroutines.withTimeoutOrNull(10_000) { s.state.first { st -> st.view?.panes?.any { it.paneId == pane } == true } } + pane + } } .onSuccess { pane -> - container.starts.started(m, dir, board = false, startNow = starts.startNow, kind = k) + // herdr-core's start was recorded as soon as it ran. + if (classic) container.starts.started(m, dir, board = false, startNow = starts.startNow, kind = k) nav.popBackStack() panes.open(nav, Routes.agent(m, pane)) } diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/terminal/TerminalScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/terminal/TerminalScreen.kt index 3456a4db..4a0d7ba3 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/terminal/TerminalScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/terminal/TerminalScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.RectangleShape import com.makefunstuff.herdr.core.terminal.LiveTerminal import com.makefunstuff.herdr.core.hc.HcTerminalStream import com.makefunstuff.herdr.core.hc.HcMapping +import com.makefunstuff.herdr.ui.manage.shepherdrsPane import com.makefunstuff.herdr.ui.design.confirmTap import com.makefunstuff.herdr.core.chat.PasswordPrompt import com.makefunstuff.herdr.ui.common.PasswordCard @@ -159,6 +160,7 @@ fun TerminalScreen(nav: NavController, machineId: String, paneId: String) { var fnPage by remember { mutableStateOf(false) } var editKeys by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) } + var confirmClose by remember { mutableStateOf(false) } var failure by remember { mutableStateOf(null) } // Scrollback extras: find, selection and resizing. @@ -455,8 +457,17 @@ fun TerminalScreen(nav: NavController, machineId: String, paneId: String) { MenuRow("Edit key bar…", HerdrIcons.Keyboard, { menu = false; editKeys = true }) MenuRow(if (bar.keepScreenOn) "Let the screen sleep" else "Keep screen on", HerdrIcons.Sun, { menu = false; container.keyBar.setKeepScreenOn(!bar.keepScreenOn) }) MenuRow("Reconnect", HerdrIcons.RefreshCcw, { menu = false; generation++ }) + // A herdr-core terminal (one New › Terminal opened, say) closes here too; Shepherdr's agents close as Shepherdr closes them. + if (core != null && !state.view.shepherdrsPane(paneId)) MenuRow("Close terminal", HerdrIcons.Trash2, { menu = false; confirmClose = true }) ChangeScreenRow { menu = false } } + if (confirmClose) { + val entry = nav.currentBackStackEntry + com.makefunstuff.herdr.ui.manage.CloseHcTerminalDialog(session, paneId, pane?.displayName ?: paneId, onDismiss = { confirmClose = false }) { + // Back from it, unless the person has gone elsewhere meanwhile. + if (nav.currentBackStackEntry == entry) nav.popBackStack() + } + } } }, ) { padding -> diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/Held.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/Held.kt index ee58373b..36f14a7b 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/Held.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/Held.kt @@ -7,7 +7,9 @@ import kotlinx.serialization.Serializable * A message written while the agent worked: it waits above the composer, held on the phone, until * you send it (Send now) or take it back (Discard). Nothing held goes by itself, so a thought half * typed during the agent's work never lands in the middle of it, and one left behind while you - * were away never goes later without you. + * were away never goes later without you. One exception: the sentence Start work holds for an agent it + * just started on herdr-core goes by itself once that agent reports it waits (within 20 s, + * [com.makefunstuff.herdr.core.hc.HcStart.deliver]), unless you sent or discarded it first. */ @Serializable data class HeldMessage(val id: String, val text: String, val at: Long) diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcClient.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcClient.kt index 759e188d..67082985 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcClient.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcClient.kt @@ -46,6 +46,20 @@ class HcClient(private val caller: ApiCaller) { suspend fun interrupt(terminal: String): HcInterrupted = caller.call("terminal.interrupt", Requests.terminal(terminal), timeoutMs = 30_000).decode() + /** + * Starts a new terminal (`hc run --bg`): [argv], or [line] through the machine's shell, else that machine's own + * shell; in [cwd], with [labels] and [env] (its terminal identity, `TERM`), on another machine hc added when [on]. No parent: the phone isn't + * a terminal, and it must outlive nothing. Returns it once it runs. + */ + suspend fun run( + line: String? = null, cwd: String? = null, labels: Map = emptyMap(), name: String? = null, on: String? = null, + env: Map = emptyMap(), argv: List = emptyList(), + ): HcTerminal = caller.call("terminal.run", Requests.run(line, cwd, labels, name, on, env, argv), timeoutMs = 30_000).decode() + + /** Ends [terminal]'s program (`hc kill`: hangup, then terminate, then kill); returns what was seen. */ + suspend fun kill(terminal: String): HcTerminal = + caller.call("terminal.kill", Requests.terminal(terminal), timeoutMs = 30_000).decode() + /** * Subscribes to every change: [HcSubscription.snapshot] first, then [HcSubscription.events] in * order. Subscribe before reading anything else, so no change slips between them. @@ -76,6 +90,19 @@ class HcClient(private val caller: ApiCaller) { obj("terminal" to terminal, "text" to text, "force" to force.takeIf { it }) fun keys(terminal: String, keys: List): JsonObject = obj("terminal" to terminal, "keys" to JsonArray(keys.map { JsonPrimitive(it) })) + fun run( + line: String?, cwd: String?, labels: Map, name: String?, on: String?, + env: Map = emptyMap(), argv: List = emptyList(), + ): JsonObject = obj( + "argv" to argv.takeIf { it.isNotEmpty() }?.let { a -> JsonArray(a.map { JsonPrimitive(it) }) }, + "line" to line, + "cwd" to cwd, + "labels" to labels.takeIf { it.isNotEmpty() }?.let { l -> JsonObject(l.mapValues { JsonPrimitive(it.value) }) }, + "name" to name, + "on" to on, + "env" to env.takeIf { it.isNotEmpty() }?.let { e -> JsonObject(e.mapValues { JsonPrimitive(it.value) }) }, + "no_parent" to true, + ) fun attach(terminal: String, cols: Int, rows: Int, history: Int, observe: Boolean): JsonObject = obj( "terminal" to terminal, "cols" to cols, diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcStart.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcStart.kt new file mode 100644 index 00000000..693a4536 --- /dev/null +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcStart.kt @@ -0,0 +1,150 @@ +package com.makefunstuff.herdr.core.hc + +import com.makefunstuff.herdr.core.api.SessionSnapshot +import com.makefunstuff.herdr.core.ssh.HerdrCommands.quote +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import java.security.SecureRandom + +/** + * Starting a terminal on herdr-core from the phone (`terminal.run`, as `hc run --bg`): a shell or an + * agent, in a space's folder, labelled into that space as the desktop app labels its own terminals + * (`herdr.space`, a tab of its own, `where`), so the desktop app and the app's [HcMapping] both put it + * there. A terminal started outside any space lands in Other terminals. + */ +object HcStart { + /** + * Where a terminal for the space [workspaceId] ([HcMapping]'s `hc:sp…` id; null for none) is started: + * the herdr-core [session] it is in, the [machine] hc added it runs on (`on`), and its labels. + */ + data class Place(val session: String?, val machine: String?, val labels: Map) + + /** + * [workspaceId]'s place for a new tab called [tabName] ([tabId]: a new tab's id, as the desktop app + * makes them). Null when the id isn't herdr-core's. + */ + fun place(workspaceId: String, spaceName: String, tabName: String, tabId: String = newTabId()): Place? { + if (!workspaceId.startsWith(HcMapping.PREFIX)) return null + val session = HcMapping.session(workspaceId) + var rest = workspaceId.removePrefix(HcMapping.scope(session)) + if (rest.startsWith(OTHER)) { + // Other terminals (`!other`, `!other@rohan`): none of the desktop app's spaces, so no labels. + return Place(session, rest.removePrefix(OTHER).removePrefix("@").ifEmpty { null }, emptyMap()) + } + // A space on a machine hc added is `rohan/sp…`; a space known only by its name is `where:` (which may hold a `/`). + var machine: String? = null + if (!rest.startsWith(WHERE) && '/' in rest) { + machine = rest.substringBefore('/') + rest = rest.substringAfter('/') + } + val where = "$spaceName / $tabName" + val labels = if (rest.startsWith(WHERE)) mapOf("herdr.tab" to tabId, "where" to "${rest.removePrefix(WHERE)} / $tabName") + else mapOf("herdr.space" to rest, "herdr.tab" to tabId, "where" to where) + return Place(session, machine, labels) + } + + /** A new tab's id as the desktop app writes them: `tb` and 32 hex digits. */ + fun newTabId(): String { + val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) } + return "tb" + bytes.joinToString("") { "%02x".format(it) } + } + + /** + * The desktop app's space on [snapshot] whose terminals sit in [folder] (its first terminal's folder), so an + * agent started there opens in it; null when none does. Only the default session's: another session's core is + * another program's (Shepherdr's), and Other terminals is no space. + */ + fun spaceIn(snapshot: SessionSnapshot?, folder: String): String? { + val f = folder.trimEnd('/') + fun desktops(id: String) = id.startsWith(HcMapping.PREFIX) && HcMapping.session(id) == null && !id.removePrefix(HcMapping.PREFIX).startsWith(OTHER) + return snapshot?.workspaces?.filter { desktops(it.workspaceId) }?.firstOrNull { ws -> + // A space on a machine hc added (`rohan/t7`) has that machine's folders, not this one's. + snapshot.panes.firstOrNull { it.workspaceId == ws.workspaceId }?.takeIf { '/' !in it.terminalId }?.cwd?.trimEnd('/') == f + }?.workspaceId + } + + /** + * The line that starts [program] through the machine's login shell, as the app's probe found it + * ([com.makefunstuff.herdr.core.start.StartWork.probeCommand]): a desktop app's core may have a + * shorter `PATH` than the person's shell. `exec` leaves the agent itself in the terminal. + */ + // POSIX only: hc runs a line with cmd.exe on Windows, so [launch] gives the program as argv there. + fun programLine(program: String): String = "exec \"\${SHELL:-/bin/sh}\" -lc " + quote("exec " + quote(program)) + + /** A name hc takes (1-32 of a-z 0-9 - _), or null. */ + fun hcName(name: String): String? = + name.lowercase().replace(Regex("[^a-z0-9_-]+"), "-").trim('-').take(32).trimEnd('-').ifEmpty { null } + + /** Starts a shell in [place] in [cwd]. Returns its pane id. */ + suspend fun shell(client: HcClient, place: Place, cwd: String?): String { + val t = client.run(cwd = cwd?.ifBlank { null }, labels = place.labels, on = place.machine, env = ENV) + return HcMapping.paneId(t.address, place.session) + } + + /** + * Starts [program] as [name] in [folder] in [place]: its pane id, as soon as it runs. Through the login shell + * ([programLine]), except on a [windows] machine (Git Bash's), where hc runs a line with cmd.exe: there the + * program itself (`argv`), as the core's `PATH` finds it. + */ + suspend fun launch(client: HcClient, place: Place, folder: String, program: String, name: String, windows: Boolean = false): String { + val line = programLine(program).takeIf { !windows } + val argv = if (windows) listOf(program) else emptyList() + val t = try { + client.run(line, folder, place.labels, hcName(name), place.machine, ENV, argv) + } catch (e: HcException) { + // Another live terminal has the name: hc numbers none for us, so it goes without one. + if (e.kind != "name_taken") throw e + client.run(line, folder, place.labels, null, place.machine, ENV, argv) + } + return HcMapping.paneId(t.address, place.session) + } + + /** `uname -s` names a Windows machine (Git Bash, MSYS, Cygwin), as the health page reads it. */ + fun windows(uname: String): Boolean = listOf("MINGW", "MSYS", "CYGWIN").any { uname.trim().startsWith(it) } + + /** + * Gives the agent in [paneId] [text] once it reports that it waits (OSC 7501 `idle` or `done`). Nothing is typed + * blind: an agent that says nothing within [readyMs] (most kinds don't report), asks something first (`blocked`, + * such as whether to trust the folder), reports an error or ends gets no Enter it could take as an answer. + * [wanted] says whether the words are still to go (the person may have sent them, or others, or discarded them + * meanwhile): it is asked while waiting. [claim] takes them just before sending, at once, so no one else sends them + * too; false: they went another way. Returns why the words didn't go in, null when they did or are no longer wanted. + */ + suspend fun deliver( + client: HcClient, paneId: String, text: String, readyMs: Long = 20_000, + wanted: suspend () -> Boolean = { true }, claim: suspend () -> Boolean = wanted, + ): String? { + val address = HcMapping.address(paneId) ?: return "it isn't herdr-core's" + // Why not to type now, or READY. + val wait: String = withTimeoutOrNull(readyMs) { + var answer: String? = null + while (answer == null) { + val now = try { client.get(address) } catch (e: java.io.IOException) { null } + answer = when { + !wanted() -> TAKEN + now != null && !now.live -> "it ended" + now?.status?.state in setOf("idle", "done") -> READY + now?.status?.state == "blocked" -> "it asks something first" + now?.status?.state == "error" -> "it reports an error" + else -> null + } + if (answer == null) delay(500) + } + answer + } ?: "it didn't say it was ready" + if (wait == TAKEN || (wait == READY && !claim())) return null + if (wait != READY) return wait + return try { client.send(address, text.trim()); null } catch (e: java.io.IOException) { e.message ?: "it didn't take them" } + } + + /** + * The terminal the program is told it runs in: hc's core may have no `TERM` of its own (a desktop app's), and + * the phone draws what it writes with an xterm-like emulator in colour. + */ + val ENV = mapOf("TERM" to "xterm-256color", "COLORTERM" to "truecolor") + + private const val READY = "ready" + private const val TAKEN = "taken" + private const val OTHER = "!other" + private const val WHERE = "where:" +} diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/start/StartWork.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/start/StartWork.kt index 7d218f7f..de8e696e 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/start/StartWork.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/start/StartWork.kt @@ -5,6 +5,7 @@ import com.makefunstuff.herdr.core.api.IntegrationInfo import com.makefunstuff.herdr.core.api.SessionSnapshot import com.makefunstuff.herdr.core.host.HostSession import com.makefunstuff.herdr.core.ssh.HerdrCommands.quote +import kotlinx.coroutines.async /** * Starting work from a sentence: what you want first, then how (a board task, or an agent now) and @@ -92,8 +93,12 @@ object StartWork { * [KINDS] when neither can be read. */ suspend fun installedOn(session: HostSession): List { - val integrations = runCatching { session.awaitApi(10_000).integrations() }.getOrNull() - val probed = runCatching { probed(session.awaitConnection(10_000).run(probeCommand(), timeoutSeconds = 15).stdout) }.getOrNull() + // Both at once: on herdr-core alone (no classic herdr to ask) the probe answers while the integrations wait in vain. + val (integrations, probed) = kotlinx.coroutines.coroutineScope { + val integrations = async { if (session.state.value.classicMissing != null) null else runCatching { session.awaitApi(10_000).integrations() }.getOrNull() } + val probed = async { runCatching { probed(session.awaitConnection(10_000).run(probeCommand(), timeoutSeconds = 15).stdout) }.getOrNull() } + integrations.await() to probed.await() + } if (integrations == null && probed == null) return KINDS return installed(integrations.orEmpty(), probed.orEmpty()).ifEmpty { KINDS } } diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/HcStartTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/HcStartTest.kt new file mode 100644 index 00000000..afb11931 --- /dev/null +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/HcStartTest.kt @@ -0,0 +1,222 @@ +package com.makefunstuff.herdr.core + +import com.makefunstuff.herdr.core.api.ApiCaller +import com.makefunstuff.herdr.core.hc.HcClient +import com.makefunstuff.herdr.core.hc.HcException +import com.makefunstuff.herdr.core.hc.HcList +import com.makefunstuff.herdr.core.hc.HcMapping +import com.makefunstuff.herdr.core.hc.HcStart +import com.makefunstuff.herdr.core.hc.HcTerminal +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Starting and closing herdr-core terminals from the phone (issue #99): `hc run` into a space, `hc kill`. */ +class HcStartTest { + private val json = Json { ignoreUnknownKeys = true } + private fun res(name: String) = javaClass.getResource("/hc/$name")!!.readText() + private val list: HcList get() = json.decodeFromJsonElement(json.parseToJsonElement(res("list.json")).jsonObject["result"]!!.jsonObject) + private val snapshot get() = HcMapping.snapshot(list.terminals) + + private class Core(val answers: (String, JsonObject) -> JsonObject) : ApiCaller { + val calls = mutableListOf>() + override suspend fun call(method: String, params: JsonObject, timeoutMs: Long): JsonObject { + calls += method to params + return answers(method, params) + } + } + + private fun terminal(address: String, status: String? = null): JsonObject { + val t = HcTerminal(address = address) + val obj = json.encodeToJsonElement(t).jsonObject + return if (status == null) obj else JsonObject(obj + ("status" to JsonObject(mapOf("state" to JsonPrimitive(status))))) + } + + @Test + fun aSpacesTerminalIsLabelledIntoANewTabOfIt() { + val p = HcStart.place("hc:sp1", "herdr-android", "Terminal", tabId = "tbnew")!! + assertNull(p.session) + assertNull(p.machine) + assertEquals(mapOf("herdr.space" to "sp1", "herdr.tab" to "tbnew", "where" to "herdr-android / Terminal"), p.labels) + // The app maps it back into the same space, as a tab of its own. + val t = HcTerminal(address = "t9", labels = p.labels, cwd = "/home/dev/src/demo") + val snap = HcMapping.snapshot(list.terminals + t) + val pane = snap.panes.single { it.paneId == "hc:t9" } + assertEquals("hc:sp1", pane.workspaceId) + assertEquals("Terminal", snap.tabs.single { it.tabId == pane.tabId }.label) + } + + @Test + fun placesInOtherSessionsMachinesAndOtherTerminals() { + assertNull(HcStart.place("w1", "classic", "Terminal")) + val other = HcStart.place("hc:@shepherdr:sp7", "app", "claude", tabId = "tb1")!! + assertEquals("shepherdr", other.session) + assertEquals("sp7", other.labels["herdr.space"]) + val remote = HcStart.place("hc:rohan/sp2", "scratch", "Terminal", tabId = "tb1")!! + assertEquals("rohan", remote.machine) + assertEquals("sp2", remote.labels["herdr.space"]) + val named = HcStart.place("hc:where:a/b", "a/b", "Terminal")!! + assertNull(named.machine) + // Its own tab too: a second terminal there doesn't land in the first one's. + assertEquals("a/b / Terminal", named.labels["where"]) + assertTrue(named.labels["herdr.tab"]!!.startsWith("tb")) + assertTrue(HcStart.place("hc:where:a/b", "a/b", "Terminal")!!.labels["herdr.tab"] != named.labels["herdr.tab"]) + assertEquals(HcStart.Place(null, null, emptyMap()), HcStart.place(HcMapping.OTHER, "Other terminals", "Terminal")) + assertEquals(HcStart.Place(null, "rohan", emptyMap()), HcStart.place("${HcMapping.OTHER}@rohan", "Other terminals on rohan", "Terminal")) + } + + @Test + fun anAgentStartsInTheSpaceThatSitsInItsFolder() { + assertEquals("hc:sp1", HcStart.spaceIn(snapshot, "/home/dev/src/demo/")) + assertNull(HcStart.spaceIn(snapshot, "/elsewhere")) + // Only the desktop app's own (default) session: not Shepherdr's session, not Other terminals. + val shepherdr = HcMapping.snapshot(listOf(HcTerminal(address = "t5", cwd = "/srv/app", labels = mapOf("herdr.space" to "sp9", "where" to "app / 1"))), session = "shepherdr") + val other = HcMapping.snapshot(listOf(HcTerminal(address = "t6", cwd = "/srv/loose"))) + assertNull(HcStart.spaceIn(shepherdr, "/srv/app")) + assertNull(HcStart.spaceIn(other, "/srv/loose")) + } + + @Test + fun namesAndLines() { + assertEquals("add-last-answered", HcStart.hcName("Add last answered")) + assertEquals(32, HcStart.hcName("a".repeat(40))!!.length) + assertNull(HcStart.hcName("!!")) + assertEquals("exec \"\${SHELL:-/bin/sh}\" -lc 'exec '\\''claude'\\'''", HcStart.programLine("claude")) + assertTrue(HcStart.newTabId().matches(Regex("tb[0-9a-f]{32}"))) + } + + @Test + fun aShellRunsWithItsLabelsAndNoParent() = runBlocking { + val core = Core { method, _ -> if (method == "terminal.run") terminal("t9") else JsonObject(emptyMap()) } + val place = HcStart.place("hc:@shepherdr:sp1", "herdr-android", "Terminal", tabId = "tbnew")!! + val pane = HcStart.shell(HcClient(core), place, "/home/dev/src/demo") + assertEquals("hc:@shepherdr:t9", pane) + val (method, params) = core.calls.single() + assertEquals("terminal.run", method) + assertEquals("/home/dev/src/demo", params["cwd"]!!.jsonPrimitive.content) + assertEquals("true", params["no_parent"]!!.jsonPrimitive.content) + assertNull(params["line"], "no line: the machine's own shell") + assertEquals("xterm-256color", params["env"]!!.jsonObject["TERM"]!!.jsonPrimitive.content) + assertEquals("sp1", params["labels"]!!.jsonObject["herdr.space"]!!.jsonPrimitive.content) + } + + private val nowhere = HcStart.Place(null, null, emptyMap()) + + @Test + fun anAgentRunsThroughTheLoginShellUnderItsName() = runBlocking { + val core = Core { method, _ -> if (method == "terminal.run") terminal("t9") else JsonObject(emptyMap()) } + assertEquals("hc:t9", HcStart.launch(HcClient(core), nowhere, "/src", "claude", "Fix the login")) + val run = core.calls.single().second + assertEquals("fix-the-login", run["name"]!!.jsonPrimitive.content) + assertEquals("/src", run["cwd"]!!.jsonPrimitive.content) + assertEquals(HcStart.programLine("claude"), run["line"]!!.jsonPrimitive.content) + } + + @Test + fun onWindowsTheProgramRunsAsArgv() = runBlocking { + // hc runs a line with cmd.exe there: no POSIX login shell, the program itself. + val core = Core { _, _ -> terminal("t9") } + HcStart.launch(HcClient(core), nowhere, "/c/src", "claude", "fix", windows = true) + val run = core.calls.single().second + assertNull(run["line"]) + assertEquals("claude", run["argv"]!!.jsonArray.single().jsonPrimitive.content) + assertTrue(HcStart.windows("MINGW64_NT-10.0-26100\n")) + assertTrue(!HcStart.windows("Linux")) + } + + @Test + fun aTakenNameStartsItWithoutOne() = runBlocking { + val core = Core { method, params -> + when { + method == "terminal.run" && params["name"] != null -> throw HcException("name_taken", "name taken") + else -> terminal("t9") + } + } + assertEquals("hc:t9", HcStart.launch(HcClient(core), nowhere, "/src", "codex", "fix")) + assertEquals(listOf(true, false), core.calls.map { it.second["name"] != null }) + } + + @Test + fun theWordsGoInOnceItSaysItWaits() = runBlocking { + var gets = 0 + val core = Core { method, _ -> + when (method) { + "terminal.get" -> terminal("t9", status = when (++gets) { 1 -> null; 2 -> "working"; else -> "idle" }) + else -> JsonObject(mapOf("sent" to JsonPrimitive(true))) + } + } + assertNull(HcStart.deliver(HcClient(core), "hc:t9", " fix the login ")) + assertEquals(listOf("terminal.get", "terminal.get", "terminal.get", "terminal.send"), core.calls.map { it.first }) + assertEquals("fix the login", core.calls.last().second["text"]!!.jsonPrimitive.content) + } + + @Test + fun aQuestionGetsNoEnter() = runBlocking { + val core = Core { _, _ -> terminal("t9", status = "blocked") } + assertEquals("it asks something first", HcStart.deliver(HcClient(core), "hc:t9", "fix it")) + assertTrue(core.calls.none { it.first == "terminal.send" }, "an Enter could answer its question") + } + + @Test + fun anAgentThatNeverSaysItIsReadyIsTypedNothing() = runBlocking { + val core = Core { _, _ -> terminal("t9") } + assertEquals("it didn't say it was ready", HcStart.deliver(HcClient(core), "hc:t9", "fix it", readyMs = 1_200)) + assertTrue(core.calls.none { it.first == "terminal.send" }) + } + + @Test + fun anErrorIsSaidAsOne() = runBlocking { + val core = Core { _, _ -> terminal("t9", status = "error") } + assertEquals("it reports an error", HcStart.deliver(HcClient(core), "hc:t9", "fix it")) + assertTrue(core.calls.none { it.first == "terminal.send" }) + } + + @Test + fun wordsThePersonTookOverAreNotSent() = runBlocking { + // Ready, but the person sent (or discarded) the held words meanwhile: nothing goes twice. + val ready = Core { _, _ -> terminal("t9", status = "idle") } + assertNull(HcStart.deliver(HcClient(ready), "hc:t9", "fix it", wanted = { false })) + assertTrue(ready.calls.none { it.first == "terminal.send" }) + // Taken over while it still starts: the wait ends there, with nothing to say. + var asks = 0 + val starting = Core { _, _ -> terminal("t9") } + assertNull(HcStart.deliver(HcClient(starting), "hc:t9", "fix it", readyMs = 10_000, wanted = { ++asks < 3 })) + assertTrue(starting.calls.none { it.first == "terminal.send" }) + } + + @Test + fun wordsClaimedElsewhereAsTheyGoAreNotSentTwice() = runBlocking { + // Still wanted while it waits, but Send now took them in the moment it said it was ready. + val core = Core { _, _ -> terminal("t9", status = "idle") } + assertNull(HcStart.deliver(HcClient(core), "hc:t9", "fix it", wanted = { true }, claim = { false })) + assertTrue(core.calls.none { it.first == "terminal.send" }) + } + + @Test + fun aRefusedSendIsSaid() = runBlocking { + val core = Core { method, _ -> + when (method) { + "terminal.send" -> throw HcException("blocked", "it waits for an answer") + else -> terminal("t9", status = "idle") + } + } + assertEquals("It waits for an answer.", HcStart.deliver(HcClient(core), "hc:t9", "fix it")) + } + + @Test + fun closeKillsTheTerminal() = runBlocking { + val core = Core { _, _ -> terminal("t9") } + HcClient(core).kill("t9") + assertEquals("terminal.kill" to JsonObject(mapOf("terminal" to JsonPrimitive("t9"))), core.calls.single()) + } +} diff --git a/docs/HERDR_CORE.md b/docs/HERDR_CORE.md index 6545abc1..8cb1f7c6 100644 --- a/docs/HERDR_CORE.md +++ b/docs/HERDR_CORE.md @@ -10,7 +10,7 @@ Built against `hc` 0.1.14 (`hc schema`: OpenRPC, protocol generation 1). Its ans - **Other sessions** (`HcDetection`'s probe, Linux only): herdr-core runs one core per session (`HC_SESSION`), each with its state folder (`~/.local/state/herdr-core//core.lock`) and socket (`/herdr-core/.sock`). Shepherdr 3 runs its agents in the default session. The probe names every other session whose core holds its own `core.lock` (`sessionnamesocketpidexe`); a socket left behind, a holder's (`*.holder.*.sock`) or a name with other than letters, digits, `_` and `-` doesn't count. Elsewhere a core process can't be told from another session's, so off Linux only the default session is followed, as before. `connect` to another session sets `HC_SESSION` as well as `HC_SOCKET`, so even in the moment's race a core it starts would be that session's. - **Only a running core counts.** `hc connect` starts a core when none listens on its socket, and the app mustn't start a runtime on a machine whose owner doesn't use it. So `connect` is pinned to the socket found (`HC_SOCKET`) and refuses when it's gone. Left: a core that stops in the moment between the probe and `connect` gets a new core, as does, off Linux, a crashed core's socket while another session's core runs. A core found missing is looked for again every minute (the desktop app started later), one that went (a restart, an hc update) after 3 s; with no core running that is one short shell (a `test` of the socket and, on Linux, a look at each session's `core.lock` in `/proc/locks`) and nothing more. - **Transport** (`HcConnection`): one ` connect` exec channel per machine on the existing sshj connection, kept for the connection's life and closed with it. `hello {generation: 1, framed: true}` as one JSON line, then framed mode: `[channel u32 LE][kind u8][len u32 LE][payload]`. Channel 0 carries JSON-RPC calls side by side; the event subscription and each attached terminal get a channel of their own, credited back as the app reads, so the one channel carries everything. -- **Client** (`HcClient`): `list`, `get`, `read`, `send`, `keys`, `interrupt`, `subscribe` (snapshot, then events) and `attach` (raw terminal bytes, keys, resize). Errors come back as `HcException` with hc's stable `kind` (`not_found`, `blocked`, …). +- **Client** (`HcClient`): `list`, `get`, `read`, `send`, `keys`, `interrupt`, `run`, `kill`, `subscribe` (snapshot, then events) and `attach` (raw terminal bytes, keys, resize). Errors come back as `HcException` with hc's stable `kind` (`not_found`, `blocked`, …). - **In the app** (`HostSession`): a machine running both shows both. `HostState.herdrCore` holds herdr-core's version, the core's name and its terminals as a `SessionSnapshot`, next to classic herdr's `HostState.snapshot`, which doesn't change; `awaitHerdrCore()` gives the client. herdr-core is followed outside the classic session's own scope, so a herdr-core failure or hang never fails or holds up the classic session. - **herdr-core alone**: where classic herdr is missing (not installed, its session not there or not running) but herdr-core runs, the session stays connected for herdr-core and is **online** while herdr-core answers; `HostState.classicMissing` says what classic herdr lacks (`HostState.classic` is false), and the status says it again while herdr-core is gone too. Classic herdr is looked for again every two minutes, as that status's retry would, and at once on Retry; once it answers, the session starts over with both. A dropped SSH link ends the session as usual (checked every 5 s). @@ -49,7 +49,7 @@ What follows on the screens: - **Plugins, integrations, agent manifests, config reload**: herdr's; hc has templates (`templates.list`) instead. - **SSH sessions**: the one `hc connect` holds an SSH session for the connection's life, beside classic herdr's (OpenSSH allows 10 by default). - **Ordering against classic agents**: `stateChangeSeq` is an epoch time here and a counter in classic herdr; sorting one machine's agents works, mixing both needs a common key (HA-6). -- **Creating workspaces and tabs**: spaces and tabs are the desktop app's (`herdr-app do`, a GUI control socket); hc can start a terminal (`terminal.run`) and label it into a space. +- **Creating workspaces**: spaces are the desktop app's (`herdr-app do`, a GUI control socket). A tab is made by labelling a new terminal into its space (see Starting and closing). - **Agent sessions** (`agent_session`): none; the app sets one of kind `pid` (`HcMapping.sessionOf`), see Chats. - **herdr's "seen"** (done becomes idle once looked at): hc can't tell; `turn` is the closest. @@ -59,9 +59,10 @@ What follows on the screens: - **Keys and words**: `HostSession.awaitAgent(paneId)` gives an `AgentPort`: herdr-core's (`HcAgentPort`) for a pane id with `hc:`, else classic herdr's. `AgentInput` (the chat, the Needs you card's Yes and No, a notification's buttons, Stop on a Live Update) speaks only to it, so an answer is read off the screen again and pressed the same way on either. - **Chat**: Claude Code's and codex's transcripts, found from the agent's process (`AgentSessionRef` kind `pid`, which `TranscriptFollower.FIND` looks up on the machine), folded as a classic agent's are; else the agent's screen (`terminal.read`). A question on it gets its card and buttons as a classic agent's does. Classic herdr's own actions (rename, focus, why this state, activity) are left out for herdr-core's agents. - **Terminal**: `HcTerminalStream`; the desktop window taking the size back (`size` event, `HerdrCoreState.sizes`) resizes the phone's emulator. hc has no scroll request, so the phone keeps the history, as a classic terminal does: the emulator keeps the 2000 lines that scroll off its main screen, and on attaching `terminal.read`'s history (asked just before the attach, 1000 lines, as text without colours) goes above the first paint where its tail meets it (`TerminalHistory.above`). Swiping scrolls through it, typing or **Back to the screen** returns; Find searches it on the phone (`TerminalFind`), marking the match and scrolling to it. Selection by herdr and links are classic herdr's; selected text is read off the phone's own screen (or the history scrolled to) instead. -- **Machine screen**: one **Spaces** list, classic herdr's workspaces and herdr-core's spaces together by name (no actions on herdr-core's); on a machine running both, each says which it is in a detail line (`classic herdr`, `herdr-core 0.1.14`), never a heading. +- **Machine screen**: one **Spaces** list, classic herdr's workspaces and herdr-core's spaces together by name (none of classic herdr's actions on herdr-core's; a long press closes a herdr-core terminal, see Starting and closing); on a machine running both, each says which it is in a detail line (`classic herdr`, `herdr-core 0.1.14`), never a heading. - **Notifications**: herdr-core's snapshots raise the session's notices as classic herdr's do (none on the first look). While the app is closed, herdr-push (version 9) reads the default core's agents on its socket (plain JSON-RPC, `terminal.list` and `terminal.read`; connecting never starts a core) and pushes them with the classic ones; the app's own look reads them as far as herdr-core was found. herdr-push still reads the default session alone: other sessions' agents raise notices while the app runs, not yet while it is closed. - **Which machine**: herdr-core belongs to the host, not a herdr session, so the app follows it from the machine entry for classic herdr's default session only: two machines in the app for two sessions of one host show its agents once. - **Several herdr-core sessions**: `HostSession` follows each session that runs (one `hc connect` each, at most three besides the default one: each holds an SSH channel). A session with no live terminal (a preview's) is let go again after its first look and looked at again a minute later. Ids keep the session apart: the default session's stay `hc:t7`, another's are `hc:@:t7` (`HcMapping.scope`, `HcMapping.session`), so the same `t7` in two sessions is two panes, and each terminal is in exactly one. `HostState.herdrCore` is every session's state as one (`HerdrCoreState.merge`: the default session's spaces first; `sessions` names them), and `awaitHerdrCore(paneId = …)`/`awaitAgent` reach the pane's own session. A machine where classic herdr is missing is online while any session's core runs. - **Shepherdr's agents**: Shepherdr 3 labels the agents it starts (`herdr.role`, `herdr.coordinator.space` on a space's Assistant; `herdr.task` (`#`) and `herdr.task.space` on a task's worker, which herdr-task names `-`). The app calls them by their task (`HERD-8`, `HERD-8 reviewer`) or as `<space> Assistant`, and keeps the task and space on the agent (`AgentInfo.shepherdrTask`, `shepherdrSpace`; `HcMapping.shepherdrTask` takes the ref's letters from the worker's name only when its number is the label's). A worker sits in its Assistant's space and tab. On a machine whose board is Shepherdr, opening one from the Tasks list, Needs you, a notice or notification, search or the brief opens its task's chat or its space's Assistant, where its cards are (`Panes.openAgent`); the machine screen still opens its screen and terminal. - **Order**: classic herdr's `stateChangeSeq` is a counter, herdr-core's the time in ms, so on the Tasks list a herdr-core agent sorts before classic ones of the same state. +- **Starting and closing** (`HcStart`, issue #99): where a space is herdr-core's alone (no classic workspace of that name, no Shepherdr), the space's **⋯ › New › Terminal** starts one with `terminal.run` (`hc run --bg`): the machine's own shell, in the folder of the space's first terminal, no parent, `TERM=xterm-256color` and `COLORTERM=truecolor` (a desktop app's core may have no `TERM`), labelled as the desktop app labels its own (`herdr.space`, a new `herdr.tab` id, `where` `<space> / Terminal`), since the desktop app rebuilds a space's tabs from its terminals' labels; in the space's own session, and on a machine hc added with `on`; **Other terminals** gets no labels. **Start work › Agent now** offers a machine with herdr-core alone too: the agent's program runs through the login shell (`exec "$SHELL" -lc 'exec claude'`, as the probe found it) in the folder, named after the sentence (without a name where another live terminal has it), in the default session's space that sits in that folder as a tab of its own, else in Other terminals. Its chat opens as soon as it runs, with the sentence held above its box (the held-message list, to send or discard by hand meanwhile); it goes in with `terminal.send` (on the app's scope, so leaving the chat doesn't lose it, and only while it is still held) only once the agent reports that it waits (`idle` or `done`, within 20 s): nothing is typed into one that asks first (`blocked`, such as whether to trust the folder), reports an error or says nothing (most kinds report no status), where an Enter could answer a question nobody saw. Then the sentence stays held, and a toast says why. On a Windows machine (`uname -s` says MINGW, MSYS or CYGWIN: Git Bash), where hc runs a `line` with cmd.exe, the program goes as `argv` instead, as the core's `PATH` finds it. **Close terminal** in a herdr-core agent's **⋯**, and a long press on a herdr-core terminal on the machine screen, end it with `terminal.kill` (`hc kill`: hangup, then terminate, then kill) after a confirmation, as classic herdr's Close pane asks; so does **Close terminal** in a herdr-core terminal's **⋯**, which is where a shell New › Terminal opened is. The chat or terminal goes back once it is closed. The killed terminal's finished record stays in `hc ls --all` (the phone shows live terminals only); the app doesn't `hc rm` it, leaving that to hc and the desktop app. Where classic herdr has the space, its own new tab and close are used, and Shepherdr's spaces (any holding its Assistant or a task's worker, one rule for every Close) close as Shepherdr closes them, as before. diff --git a/tools/ui-preview/src/main/kotlin/preview/Scenes.kt b/tools/ui-preview/src/main/kotlin/preview/Scenes.kt index 9e973f4a..91c1da89 100644 --- a/tools/ui-preview/src/main/kotlin/preview/Scenes.kt +++ b/tools/ui-preview/src/main/kotlin/preview/Scenes.kt @@ -338,6 +338,22 @@ fun scenes(): List<Scene> = listOf( Scene("terminal-desktop", route = Routes.terminal(DEVBOX, DemoCore.SHELL), setup = { desktopApp() }), // Its history is the phone's own (filled from terminal.read), found in as classic herdr's scrollback is: the match marked and scrolled to. Scene("terminal-desktop-find", route = Routes.terminal(DEVBOX, DemoCore.SHELL), setup = { desktopApp() }, actions = { tapLabel("Find"); type("failed"); repeat(4) { tapLabel("Older") } }), + // Starting and closing on herdr-core alone (mac-mini, issue #99): the space's ⋯ offers a new Terminal (`hc run`), Start + // work an agent there, and an agent's ⋯ (or a long press on the machine screen) closes its terminal (`hc kill`), asked first. + Scene("chat-tab-menu-desktop-only", route = Routes.CHAT, setup = { desktopApp() }, space = "${Demo.MINI}/website", actions = { tapLabel("Tabs and new") }), + Scene("start-desktop-only", route = Routes.start(Demo.MINI, "Check every link on the website and fix the broken ones"), setup = { desktopApp() }, actions = { tapExact("Agent now") }), + // Just started from Start work: the chat opens at once, the sentence held above its box until the agent says it waits. + Scene("chat-desktop-started", route = Routes.agent(Demo.MINI, "hc:t1"), setup = { + desktopApp() + val key = com.makefunstuff.herdr.data.DraftStore.agent(Demo.MINI, "hc:t1") + composer = com.makefunstuff.herdr.data.ComposerStore.State(held = mapOf(key to listOf( + com.makefunstuff.herdr.core.chat.HeldMessage("start-1", "Check every link on the website and fix the broken ones", Demo.NOW - 5_000), + ))) + }), + Scene("chat-desktop-more", route = Routes.agent(Demo.MINI, "hc:t2"), setup = { desktopApp() }, actions = { tapLabel("More") }), + Scene("chat-desktop-close", route = Routes.agent(Demo.MINI, "hc:t2"), setup = { desktopApp() }, actions = { tapLabel("More"); tapExact("Close terminal") }), + Scene("terminal-desktop-menu", route = Routes.terminal(DEVBOX, DemoCore.SHELL), setup = { desktopApp() }, actions = { tapLabel("More") }), + Scene("machine-desktop-only-close", route = Routes.machine(Demo.MINI), setup = { desktopApp() }, actions = { settle(); tapText("blog"); longPressText("codex") }), // Shepherdr 3 on devbox: its agents run in herdr-core beside classic herdr, called by their task or space (from // Shepherdr's labels); on the Agents list with the rest (tapping one opens its task's chat), and on the machine screen. Scene("agents-shepherdr3", heightDp = 1700, setup = { home(); shepherdr3() }),