Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Swipe an agent left for **Pin** (it stays on top, under Needs you, in the order

The search icon on **Agents**, **Board** and a machine (or **Ctrl+K** with a hardware keyboard, from anywhere) opens one box for the agents, the boards' tasks, the machines, their workspaces and the words in the recent chats. Chips above the results filter them to what **Needs you**, what is **Working** and what is **Done**, each with its count. From a machine it searches only that machine, until its chip is taken off. Every word typed has to be in a result, and a word at the start of a name counts most; with nothing typed it lists the agents, the tasks waiting for you and **Start work…**.

Words in the chats are looked for on the machines themselves: each running agent's own transcript (Claude Code, codex, pi and omp) is searched where it is, a moment after you stop typing (three letters at the least), and what was said around the words shows with who said it and when; tool calls and their output don't count. Under the results, **New task about "…"** starts work about the words, and **Open a terminal in …** opens a shell in the workspace found (each workspace row has the same terminal button). Enter opens the first result, Esc goes back.
Words in the chats are looked for on the machines themselves: each running agent's own transcript (Claude Code, codex, pi and omp) is searched where it is, a moment after you stop typing (three letters at the least), and what was said around the words shows with who said it and when; tool calls and their output don't count. Under the results, **New task about "…"** starts work about the words, and **Open a terminal in …** opens a shell in the workspace found (each workspace row of classic herdr has the same terminal button; herdr-core's workspaces open no terminal from the phone). Enter opens the first result, Esc goes back.

## Start work

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String
// Words in the chats: asked of the machines a moment after typing stops, each in its own time.
val chatHits = remember { mutableStateMapOf<String, List<ChatSearch.Hit>>() }
var looking by remember { mutableStateOf(0) }
val online = hosts.filter { it.online && it.snapshot != null && (scoped == null || it.machine.id == scoped) }
val chatKey = online.joinToString(",") { h -> h.machine.id + ":" + h.snapshot!!.agents.joinToString { it.paneId + it.agentSession?.value.orEmpty() } }
val online = hosts.filter { it.online && it.view != null && (scoped == null || it.machine.id == scoped) }
val chatKey = online.joinToString(",") { h -> h.machine.id + ":" + h.view!!.agents.joinToString { it.paneId + it.agentSession?.value.orEmpty() } }
LaunchedEffect(query.trim(), chatKey) {
chatHits.clear()
val q = query.trim()
Expand All @@ -128,13 +128,16 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String
coroutineScope {
online.map { h ->
async {
val chats = h.snapshot!!.agents.mapNotNull { a -> a.agentSession?.let { ChatSearch.Chat(a.paneId, it) } }
val command = ChatSearch.command(q, chats) ?: return@async
val chats = h.view!!.agents.mapNotNull { a -> a.agentSession?.let { ChatSearch.Chat(a.paneId, it) } }
val commands = ChatSearch.commands(q, chats).ifEmpty { return@async }
looking++
try {
val session = container.hosts.session(h.machine.id) ?: return@async
val out = runCatching { session.awaitConnection(10_000).run(command, timeoutSeconds = 15).stdout }.getOrNull() ?: return@async
val kinds = h.snapshot!!.agents.associate { it.paneId to (it.agentSession?.agent ?: it.agent.orEmpty()) }
val out = runCatching {
val conn = session.awaitConnection(10_000)
buildString { for (c in commands) append(conn.run(c, timeoutSeconds = 15).stdout).append('\n') }
}.getOrNull() ?: return@async
val kinds = h.view!!.agents.associate { it.paneId to (it.agentSession?.agent ?: it.agent.orEmpty()) }
chatHits[h.machine.id] = ChatSearch.parse(out, q, kinds)
} finally {
looking--
Expand Down Expand Up @@ -262,7 +265,7 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String
modifier = Modifier.padding(horizontal = Space.screen + Space.xs, vertical = Space.l),
)
}
val ws = results.workspaces.firstOrNull()
val ws = results.workspaces.firstOrNull { it.opensTerminal }
actions(
listOfNotNull(
Action("New task about “${query.trim()}”…", "Add it to a board, or start an agent now", HerdrIcons.Plus, accent = true) { startWork(query) },
Expand Down Expand Up @@ -345,7 +348,7 @@ private fun WorkspaceRow(w: WorkspaceHit, showMachine: Boolean, opening: Boolean
leading = { IconTile(if (w.workspace.worktree != null) HerdrIcons.FolderGit2 else HerdrIcons.Folder, size = 36.dp) },
trailing = {
if (opening) Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { Spinner(size = 20.dp) }
else BarIcon(HerdrIcons.SquareTerminal, "Open a terminal in ${w.name}", onTerminal)
else if (w.opensTerminal) BarIcon(HerdrIcons.SquareTerminal, "Open a terminal in ${w.name}", onTerminal)
},
onClickLabel = "Open its machine",
onClick = onOpen,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import java.time.Instant

/**
* Words in the recent chats: the conversations of the agents running on a machine, searched where
* they are. One command per machine finds each agent's transcript as the chat does
* they are. One command per machine (a few for many agents) finds each agent's transcript as the chat does
* ([TranscriptFollower.FIND]) and prints its newest lines holding the words, which the agent's own
* reader then turns into what was said. Only what was said counts, not tool calls or their output.
* As in the rest of the search, every word has to be in it, anywhere: the machine looks for the
Expand All @@ -25,14 +25,15 @@ object ChatSearch {
const val MIN_QUERY = 3

// $1 the word looked for, then pane, kind, value and agent for each chat.
// Git Bash's grep aborts on -i with -F, so when grep fails (status above 1) awk matches the lines instead.
private val SCRIPT = """
q="${'$'}1"; shift
while [ ${'$'}# -ge 4 ]; do
pane="${'$'}1"; kind="${'$'}2"; value="${'$'}3"; agent="${'$'}4"; shift 4; f=""
""".trimIndent() + "\n" + TranscriptFollower.FIND + "\n" + """
if [ -n "${'$'}f" ] && [ -f "${'$'}f" ]; then
echo "$MARK${'$'}pane"
grep -i -F -- "${'$'}q" "${'$'}f" 2>/dev/null | awk 'length(${'$'}0) < $MAX_LINE' | tail -n $LINES
{ grep -i -F -- "${'$'}q" "${'$'}f" 2>/dev/null; [ ${'$'}? -le 1 ] || Q="${'$'}q" awk 'BEGIN { q = tolower(ENVIRON["Q"]) } index(tolower(${'$'}0), q)' "${'$'}f"; } | awk 'length(${'$'}0) < $MAX_LINE' | tail -n $LINES
fi
done
""".trimIndent()
Expand All @@ -43,15 +44,42 @@ object ChatSearch {
/** Something said in a chat that holds the words: a piece of it around them, and when, when the transcript says. */
data class Hit(val paneId: String, val you: Boolean, val snippet: String, val at: Long?)

/** The command for [chats] on one machine; null when there is nothing to look in. */
fun command(query: String, chats: List<Chat>): String? {
val q = words(query).maxByOrNull { it.length } ?: return null
/**
* The longest command sent, counting each double quote twice: Windows hands it to Git's bash on a
* command line cut at 8191 characters, with every `"` written `\"`, and a cut command fails whole.
*/
private const val MAX_COMMAND = 7_800

/**
* The commands for [chats] on one machine, run one after another; none when there is nothing to look
* in. Each stays within what Windows takes, so a machine with many agents gets more than one.
*/
fun commands(query: String, chats: List<Chat>): List<String> {
val q = words(query).maxByOrNull { it.length } ?: return emptyList()
val readable = chats.filter { Transcript.forAgent(it.session.agent) != null && it.session.value.isNotEmpty() }
if (query.trim().length < MIN_QUERY || readable.isEmpty()) return null
val args = readable.flatMap { listOf(it.paneId, it.session.kind, it.session.value, it.session.agent) }
return "sh -c ${quote(SCRIPT)} herdr-chat-search " + (listOf(q) + args).joinToString(" ") { quote(it) }
if (query.trim().length < MIN_QUERY || readable.isEmpty()) return emptyList()
val head = "sh -c ${quote(SCRIPT)} herdr-chat-search ${quote(q)}"
val out = ArrayList<String>()
val command = StringBuilder(head)
var length = windowsLength(head)
for (chat in readable) {
val part = listOf(chat.paneId, chat.session.kind, chat.session.value, chat.session.agent).joinToString("") { " " + quote(it) }
if (command.length > head.length && length + windowsLength(part) > MAX_COMMAND) {
out += command.toString()
command.setLength(0)
command.append(head)
length = windowsLength(head)
}
command.append(part)
length += windowsLength(part)
}
out += command.toString()
return out
}

/** How long [s] is on a Windows command line, where each double quote is written `\"`. */
private fun windowsLength(s: CharSequence): Int = s.length + s.count { it == '"' }

/**
* The hits in what [command] printed: the newest first within each chat, at most [perChat] of
* them. [agents] gives each pane's agent kind, whose reader understands its lines.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,33 @@ object TranscriptFollower {
* agent's process, which reports no session) is looked up as Shepherdr 3.1 does: Claude Code
* names its session in `sessions/<pid>.json`, codex keeps its rollout file open; the process's
* children are tried too, for an agent started through a wrapper.
* On Windows (Git Bash) the children come from the process table, as there is no `pgrep`.
*/
internal val FIND = """
if [ "${'$'}kind" = path ]; then
f="${'$'}value"
case "${'$'}f" in "~/"*) f="${'$'}HOME/${'$'}{f#\~/}" ;; esac
elif [ "${'$'}kind" = pid ]; then
kids() { for k in ${'$'}(pgrep -P "${'$'}1" 2>/dev/null); do echo "${'$'}k"; kids "${'$'}k"; done; }
case "${'$'}(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN*)
# Git Bash has no pgrep and can't see Windows processes. Read the table of every process and
# its parent (once for all the chats of a search, even when it comes back empty) and walk it
# down from the pid; a parent id can be stale (Windows reuses pids), so each pid is taken once.
[ -n "${'$'}ptried" ] || ptried=1 ptable=${'$'}(powershell.exe -NoProfile -Command 'Get-CimInstance Win32_Process | Format-Table -HideTableHeaders ProcessId,ParentProcessId' 2>/dev/null </dev/null | tr -d '\r')
kids() {
printf '%s\n' "${'$'}ptable" | awk -v root="${'$'}1" '
${'$'}1 ~ /^[0-9]+${'$'}/ && ${'$'}2 ~ /^[0-9]+${'$'}/ { n++; pid[n] = ${'$'}1; par[n] = ${'$'}2 }
END {
seen[root] = 1; queue[1] = root; head = 1; tail = 1
while (head <= tail) {
cur = queue[head++]
for (i = 1; i <= n; i++) if (par[i] == cur && !(pid[i] in seen)) { seen[pid[i]] = 1; queue[++tail] = pid[i]; print pid[i] }
}
}'
} ;;
*)
kids() { for k in ${'$'}(pgrep -P "${'$'}1" 2>/dev/null); do echo "${'$'}k"; kids "${'$'}k"; done; } ;;
esac
for p in "${'$'}value" ${'$'}(kids "${'$'}value"); do
case "${'$'}agent" in
claude)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.makefunstuff.herdr.core.api.AgentStatus
import com.makefunstuff.herdr.core.api.WorkspaceInfo
import com.makefunstuff.herdr.core.board.WorkStatus
import com.makefunstuff.herdr.core.board.WorkTask
import com.makefunstuff.herdr.core.hc.HcMapping
import com.makefunstuff.herdr.core.home.Home
import com.makefunstuff.herdr.core.home.HomeAgent
import com.makefunstuff.herdr.core.home.HomeBoard
Expand Down Expand Up @@ -55,6 +56,9 @@ data class WorkspaceHit(
val branch: String? = null,
) {
val name: String get() = workspace.label.ifBlank { workspace.workspaceId }

/** Whether the phone can open a terminal in it: only classic herdr opens one, herdr-core doesn't. */
val opensTerminal: Boolean get() = !workspace.workspaceId.startsWith(HcMapping.PREFIX)
}

/** What one query found across the machines, best first in each kind. */
Expand All @@ -70,7 +74,7 @@ data class SearchResults(
}

/**
* One box for everything the app holds: the machines' agents and workspaces (their last snapshots),
* One box for everything the app holds: the machines' agents and workspaces (their last views, herdr-core's too),
* the boards' tasks (as last read), and the machines. Every word of the query has to be somewhere in
* an item; a word at the start of its name counts most. An empty query lists what is there, the
* agents and tasks waiting for you first.
Expand Down Expand Up @@ -102,7 +106,7 @@ object Search {
.filter { words.isNotEmpty() || it.first.task.status.needsYou }
val machineScores = if (words.isEmpty()) emptyList() else inScope.map(::MachineHit).score(words) { h -> listOf(h.host.machine.label, h.host.machine.target) }
val workspaces = if (words.isEmpty()) emptyList() else inScope.flatMap { s ->
val snap = s.snapshot ?: return@flatMap emptyList()
val snap = s.view ?: return@flatMap emptyList()
snap.workspaces.map { ws ->
val folder = ws.worktree?.checkoutPath?.ifEmpty { null } ?: snap.panes.firstOrNull { it.workspaceId == ws.workspaceId }?.cwd
WorkspaceHit(s.machine.id, s.machine.label, ws, folder, s.workspaceMeta[ws.workspaceId]?.get("branch")?.ifEmpty { null })
Expand Down
34 changes: 28 additions & 6 deletions core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.makefunstuff.herdr.core.board.WorkStatus
import com.makefunstuff.herdr.core.board.WorkTask
import com.makefunstuff.herdr.core.chat.ChatSearch
import com.makefunstuff.herdr.core.home.HomeBoard
import com.makefunstuff.herdr.core.host.HerdrCoreState
import com.makefunstuff.herdr.core.host.HostState
import com.makefunstuff.herdr.core.host.HostStatus
import com.makefunstuff.herdr.core.search.Search
Expand All @@ -31,6 +32,7 @@ import java.nio.file.Files
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

Expand Down Expand Up @@ -104,6 +106,26 @@ class SearchTest {
assertEquals(listOf("ci"), Search.of("ci-run", listOf(devbox, ci), boards).machines.map { it.machineId })
}

@Test
fun findsWorkspacesOfHerdrCoreToo() {
val core = HerdrCoreState(
"0.1.14", "devbox", "/home/dev/.local/bin/herdr-core",
SessionSnapshot(workspaces = listOf(WorkspaceInfo("hc:w1", label = "docs-site")), panes = listOf(PaneInfo("hc:t1", workspaceId = "hc:w1", tabId = "hc:w1:t1", cwd = "/home/dev/docs-site"))),
)
val both = devbox.copy(herdrCore = core)
val hit = Search.of("docs-site", listOf(both), boards).workspaces.single()
assertEquals("/home/dev/docs-site", hit.folder)
// herdr-core opens no terminal from the phone, so its row offers none.
assertFalse(hit.opensTerminal)
// Online for herdr-core alone: only its workspaces.
val alone = devbox.copy(snapshot = null, herdrCore = core)
assertEquals(listOf("docs-site"), Search.of("docs", listOf(alone), boards).workspaces.map { it.name })
// Classic herdr's still show next to them.
val classic = Search.of("herdr-android", listOf(both), boards).workspaces
assertEquals(listOf("herdr-android"), classic.map { it.name })
assertTrue(classic.single().opensTerminal)
}

@Test
fun aChipShowsOnlyItsAgentsAndTasks() {
val r = Search.of("jobs", listOf(devbox, ci), boards, SearchFilter.Working)
Expand Down Expand Up @@ -154,13 +176,13 @@ class ChatSearchTest {

private fun search(dir: java.nio.file.Path) {
val file = dir.resolve("api.jsonl").toFile().apply { writeText(transcript + "\n") }
val command = ChatSearch.command(
val command = ChatSearch.commands(
"JOBS",
listOf(
ChatSearch.Chat("w2:p1", AgentSessionRef(agent = "claude", kind = "path", value = file.path)),
ChatSearch.Chat("w9:p1", AgentSessionRef(agent = "claude", kind = "path", value = dir.resolve("gone.jsonl").toString())),
),
)!!
).single()
val p = ProcessBuilder("sh", "-c", command).redirectErrorStream(true).start()
val out = p.inputStream.bufferedReader().readText()
assertTrue(p.waitFor(10, TimeUnit.SECONDS))
Expand All @@ -184,7 +206,7 @@ class ChatSearchTest {
)
}
fun hits(q: String): List<ChatSearch.Hit> {
val command = ChatSearch.command(q, listOf(ChatSearch.Chat("w1:p1", AgentSessionRef(agent = "claude", kind = "path", value = file.path))))!!
val command = ChatSearch.commands(q, listOf(ChatSearch.Chat("w1:p1", AgentSessionRef(agent = "claude", kind = "path", value = file.path)))).single()
val p = ProcessBuilder("sh", "-c", command).redirectErrorStream(true).start()
val out = p.inputStream.bufferedReader().readText()
assertTrue(p.waitFor(10, TimeUnit.SECONDS))
Expand All @@ -202,9 +224,9 @@ class ChatSearchTest {
@Test
fun aShortQueryOrAChatItCantReadAsksNothing() {
val chat = ChatSearch.Chat("w1:p1", AgentSessionRef(agent = "claude", kind = "id", value = "abc"))
assertNull(ChatSearch.command("jo", listOf(chat)))
assertNull(ChatSearch.command("jobs", listOf(chat.copy(session = chat.session.copy(agent = "gemini")))))
assertNull(ChatSearch.command("jobs", emptyList()))
assertTrue(ChatSearch.commands("jo", listOf(chat)).isEmpty())
assertTrue(ChatSearch.commands("jobs", listOf(chat.copy(session = chat.session.copy(agent = "gemini")))).isEmpty())
assertTrue(ChatSearch.commands("jobs", emptyList()).isEmpty())
}

@Test
Expand Down
Loading
Loading