diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt index 9c6f2ec2..01312fd8 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt @@ -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 { diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt index 5d9ff6b2..e907079c 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt @@ -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 @@ -25,6 +25,7 @@ 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 @@ -32,7 +33,7 @@ object ChatSearch { """.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() @@ -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): 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): List { + 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() + 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. diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt index 62503059..86f6911c 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt @@ -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/.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); do echo "${'$'}k"; kids "${'$'}k"; done; } ;; + esac for p in "${'$'}value" ${'$'}(kids "${'$'}value"); do case "${'$'}agent" in claude) diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt index 99bebe1c..6e9fc3b7 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt @@ -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)) @@ -206,7 +206,7 @@ class ChatSearchTest { ) } fun hits(q: String): List { - 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)) @@ -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 diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt new file mode 100644 index 00000000..fd578ed3 --- /dev/null +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt @@ -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 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 `: 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 = + 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) { + 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 ""` 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")) + } +} diff --git a/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt b/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt index 91f8520f..2308b066 100644 --- a/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt +++ b/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt @@ -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]