Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,17 @@ 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.

## Managing from the phone

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).

Expand Down
17 changes: 17 additions & 0 deletions app/src/main/kotlin/com/makefunstuff/herdr/data/ComposerStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,6 +111,7 @@ fun MachineScreen(nav: NavController, machineId: String) {
var workspaceMenu by remember { mutableStateOf<WorkspaceInfo?>(null) }
var tabMenu by remember { mutableStateOf<TabInfo?>(null) }
var paneMenu by remember { mutableStateOf<PaneInfo?>(null) }
var coreClose by remember { mutableStateOf<PaneInfo?>(null) }
val selectedTab = remember { mutableStateMapOf<String, String>() }

fun openPane(pane: PaneInfo) {
Expand Down Expand Up @@ -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(),
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -551,13 +600,17 @@ 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()
val target = agent?.paneId
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) {
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 17 additions & 4 deletions app/src/main/kotlin/com/makefunstuff/herdr/ui/spaces/Spaces.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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}")
Expand Down
Loading
Loading