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
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,14 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String
online.map { h ->
async {
val chats = h.view!!.agents.mapNotNull { a -> a.agentSession?.let { ChatSearch.Chat(a.paneId, it) } }
val command = ChatSearch.command(q, chats) ?: return@async
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 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 {
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
12 changes: 6 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 @@ -176,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 @@ -206,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 @@ -224,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package com.makefunstuff.herdr.core

import com.makefunstuff.herdr.core.api.AgentSessionRef
import com.makefunstuff.herdr.core.chat.ChatSearch
import com.makefunstuff.herdr.core.chat.TranscriptFollower
import java.io.File
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/** Finding a transcript from a pane's pid on a Windows machine, where `pgrep` doesn't exist. */
class TranscriptWindowsTest {
private val session = "00000000-0000-4000-8000-000000000001"

/**
* Sets up a Windows machine in a temp home: Git Bash's `uname`, a `powershell.exe` that prints
* [table], the transcript with [jsonl] in it, and [sessionPid]'s Claude session file, if any. Runs
* [block] with the home and its `bin` (first on the PATH), then removes the home.
*/
private fun <T> onWindows(
table: String,
sessionPid: String?,
crlf: Boolean = false,
jsonl: String = "{\"a\":1}",
block: (File, File) -> T,
): T {
val home = Files.createTempDirectory("transcript-win").toFile()
try {
val bin = File(home, "bin").apply { mkdirs() }
File(bin, "uname").apply { writeText("#!/bin/sh\necho MINGW64_NT-10.0-26100\n"); setExecutable(true) }
// The real one prints CRLF line endings, and reads nothing from its stdin.
val eol = if (crlf) "\r\n" else "\n"
val rows = table.trim().lines().joinToString("") { "$it$eol" }
File(bin, "powershell.exe").apply { writeText("#!/bin/sh\necho x >> \"\$HOME/powershell.calls\"\nprintf '$rows'\n"); setExecutable(true) }
File(home, ".claude/projects/C--Users-dev-proj").apply { mkdirs() }.resolve("$session.jsonl").writeText(jsonl + "\n")
File(home, ".claude/sessions").mkdirs()
if (sessionPid != null) File(home, ".claude/sessions/$sessionPid.json").writeText("""{"pid":$sessionPid,"sessionId":"$session"}""")
return block(home, bin)
} finally {
home.deleteRecursively()
}
}

/** Runs [command] with `sh`, as the app does over SSH, and returns what it prints, giving up after 10 seconds. */
private fun sh(home: File, bin: File, command: String): String {
// Written to a file and run with `sh <file>`: on Windows, Java breaks the quotes of `sh -c`.
val file = File(home, "run.sh").apply { writeText(command + "\n") }
val out = File(home, "run.out")
val p = ProcessBuilder("sh", file.path)
.apply { environment().apply { put("HOME", home.path); put("PATH", "${bin.path}:/usr/bin:/bin"); remove("CLAUDE_CONFIG_DIR") } }
.redirectErrorStream(true)
.redirectOutput(out)
.start()
// Closing stdin is how the app ends the stream.
p.outputStream.close()
if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly().waitFor()
return out.readText()
}

/**
* Runs [TranscriptFollower.command] for the pane's pid 16744 on a Windows machine whose
* `powershell.exe` prints [table]; [sessionPid] has the Claude session file, if any.
* Returns the first line it prints.
*/
private fun run(table: String, sessionPid: String?, crlf: Boolean = false): String =
onWindows(table, sessionPid, crlf) { home, bin ->
val ref = AgentSessionRef(kind = "pid", value = "16744", agent = "claude")
sh(home, bin, TranscriptFollower.command(ref, 100)).lineSequence().firstOrNull() ?: ""
}

private fun assertFound(first: String) {
assertTrue(first.startsWith("@@file ") && first.endsWith("/$session.jsonl"), first)
}

@Test
fun findsClaudeUnderTheWindowsShell() {
assertFound(run("16744 900\n27492 16744\n31000 27492\n", sessionPid = "27492"))
}

@Test
fun findsAGrandchild() {
assertFound(run("16744 900\n27492 16744\n31000 27492\n", sessionPid = "31000"))
}

@Test
fun readsPowershellsCrlfTable() {
assertFound(run("16744 900\n27492 16744\n", sessionPid = "27492", crlf = true))
}

@Test
fun missingWithoutASessionFile() {
assertEquals("@@missing", run("16744 900\n27492 16744\n31000 27492\n", sessionPid = null))
}

@Test
fun ignoresProcessesOutsideThePanesTree() {
// 27492 belongs to another shell, so its session isn't this pane's.
assertEquals("@@missing", run("16744 900\n27492 5000\n", sessionPid = "27492"))
}

@Test
fun aStaleParentIdCannotLoopTheWalk() {
// Windows reuses pids: 16744's own parent id points back down at its child.
assertFound(run("16744 31000\n27492 16744\n31000 27492\n40000 31000\n", sessionPid = "40000"))
assertEquals("@@missing", run("16744 16744\n27492 31000\n31000 27492\n", sessionPid = null))
}

private val said = """{"type":"user","message":{"role":"user","content":"The Flaky upload test"},"timestamp":"2026-11-07T10:00:00Z","uuid":"u1"}"""

/** The chat search of word [query] on a Windows machine, with [setup] run on its `bin` first. */
private fun search(query: String, setup: (File) -> Unit = {}): List<ChatSearch.Hit> =
onWindows("16744 900\n27492 16744\n", sessionPid = "27492", jsonl = said) { home, bin ->
setup(bin)
val chat = ChatSearch.Chat("p1", AgentSessionRef(kind = "pid", value = "16744", agent = "claude"))
val stdout = sh(home, bin, ChatSearch.commands(query, listOf(chat)).single())
ChatSearch.parse(stdout, query, mapOf("p1" to "claude"))
}

private fun assertFlaky(hits: List<ChatSearch.Hit>) {
assertEquals(listOf("p1"), hits.map { it.paneId })
assertTrue("Flaky" in hits.single().snippet, hits.single().snippet)
}

@Test
fun searchFindsWordsWhenGrepAborts() {
// Git Bash's grep aborts on -i with -F: it is killed and prints nothing.
assertFlaky(search("flaky") { bin -> File(bin, "grep").apply { writeText("#!/bin/sh\nkill -ABRT \$\$\n"); setExecutable(true) } })
}

@Test
fun searchUsesTheGrepOfTheMachine() {
// With a grep that works the words are found by it, and the fallback isn't needed.
assertFlaky(search("flaky"))
}

@Test
fun aSearchReadsTheProcessTableOnceForAllItsChats() {
// Three chats, each with its own pane pid: one powershell for the machine, not one per chat.
val table = "16744 900\n27492 16744\n17000 900\n17001 17000\n18000 900\n18001 18000\n"
onWindows(table, sessionPid = "27492", jsonl = said) { home, bin ->
for (pid in listOf("17001", "18001")) File(home, ".claude/sessions/$pid.json").writeText("""{"pid":$pid,"sessionId":"$session"}""")
val chats = listOf("16744" to "p1", "17000" to "p2", "18000" to "p3").map { (pid, pane) ->
ChatSearch.Chat(pane, AgentSessionRef(kind = "pid", value = pid, agent = "claude"))
}
val stdout = sh(home, bin, ChatSearch.commands("flaky", chats).single())
assertEquals(listOf("p1", "p2", "p3"), ChatSearch.parse(stdout, "flaky", chats.associate { it.paneId to "claude" }).map { it.paneId })
assertEquals(1, File(home, "powershell.calls").readLines().size)
}
}

@Test
fun theSearchFitsTheWindowsCommandLine() {
// Git Bash gets the command as `bash.exe -c "<command>"` with each quote written \" and the line is cut at 8191 characters.
val chats = (1..12).map { ChatSearch.Chat("p$it", AgentSessionRef(kind = "pid", value = "${16000 + it}", agent = "claude")) }
val line = ChatSearch.commands("flaky", chats).single().replace("\"", "\\\"")
val room = 8191 - "\"C:\\Program Files\\Git\\bin\\bash.exe\" -c \"".length - 1
assertTrue(line.length <= room, "the search is ${line.length} characters, the room is $room")
}

@Test
fun aSearchOfManyChatsIsSplitToFitWindowsLine() {
// A machine with a few hundred agents: several commands, each on Windows' line, and every chat in one of them.
val chats = (1..300).map { ChatSearch.Chat("p$it", AgentSessionRef(kind = "path", value = "~/.claude/projects/C--Users-dev-proj/$session.jsonl", agent = "claude")) }
val commands = ChatSearch.commands("flaky", chats)
assertTrue(commands.size > 1, "${commands.size} commands")
val room = 8191 - "\"C:\\Program Files\\Git\\bin\\bash.exe\" -c \"".length - 1
for (command in commands) {
val line = command.replace("\"", "\\\"")
assertTrue(line.length <= room, "a command is ${line.length} characters, the room is $room")
}
val panes = commands.flatMap { c -> Regex("'(p\\d+)' 'path'").findAll(c).map { it.groupValues[1] }.toList() }
assertEquals(chats.map { it.paneId }, panes)
}

@Test
fun searchWithoutAMatchStaysEmpty() {
assertEquals(emptyList(), search("missing"))
}
}
2 changes: 1 addition & 1 deletion tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class FakeMachine(
appBuild?.run(command)?.let { return it }
docs?.run(command)?.let { return it }
Shell.words(command).let { w ->
// Words in the chats (ChatSearch.command): the transcripts' lines that hold them, under each chat's mark.
// Words in the chats (ChatSearch.commands): the transcripts' lines that hold them, under each chat's mark.
val chat = w.indexOf("herdr-chat-search")
if (chat >= 0) {
val q = w[chat + 1]
Expand Down
Loading