hive-bash-mcp: shorten the running-task todo label instead of embedding the whole command

The todo pushed at task start embedded the entire raw shell command
(task.cmd) as its summary — for a multi-line heredoc script (a common
agent pattern), that balloons every UI that renders todo summaries to
the command's full line count (hyperhive#3248).

short_cmd_label keeps only the first non-blank line, char-truncated to
100 chars, with a trailing ellipsis whenever either the line itself
was cut or more lines follow — so a short-looking first line ahead of
a long heredoc body still reads as truncated, not as the complete
command. The full command is still on disk in the task file for
status/view; this only shortens the todo label.
This commit is contained in:
iris 2026-08-14 01:44:29 +02:00 committed by mara
commit 31c34939ef

View file

@ -517,6 +517,34 @@ fn poll_once(socket: &Path, claimed: &Arc<Mutex<HashSet<String>>>) {
// Task execution
// ---------------------------------------------------------------------------
/// Max characters kept from the command's first line when building the
/// "running" todo summary (see `short_cmd_label`).
const CMD_LABEL_MAX_CHARS: usize = 100;
/// Build a short, single-line label for `cmd` to use in the "running"
/// todo summary. `cmd` can be an entire multi-line script (heredocs are
/// a common agent pattern) — embedding it whole balloons the todo body
/// in every UI that renders it, so this keeps only the first non-blank
/// line, char-truncated to `CMD_LABEL_MAX_CHARS`, with a trailing `…`
/// whenever either the line itself was cut or more lines follow. The
/// full command is still on disk in the task file for `status`/`view`.
fn short_cmd_label(cmd: &str) -> String {
let mut lines = cmd.lines().map(str::trim).filter(|l| !l.is_empty());
let first = lines.next().unwrap_or("");
let more_lines = lines.next().is_some();
let char_truncated = first.chars().count() > CMD_LABEL_MAX_CHARS;
let label: String = if char_truncated {
first.chars().take(CMD_LABEL_MAX_CHARS).collect()
} else {
first.to_owned()
};
if char_truncated || more_lines {
format!("{label}")
} else {
label
}
}
async fn run_task(mut task: TaskFile, socket: &Path) {
let id = task.id.clone();
tracing::info!(id = %id, cmd = %task.cmd, "bash_runner: starting task");
@ -529,7 +557,7 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
upsert_bash_todo(
socket,
&id,
format!("bash task `{id}` running: `{}`", task.cmd),
format!("bash task `{id}` running: `{}`", short_cmd_label(&task.cmd)),
)
.await;
// Best-effort: tally the normalised command head for the /stats
@ -830,10 +858,43 @@ fn done_summary(id: &str, summary: &str, output: Option<(bool, bool)>) -> String
#[cfg(test)]
mod tests {
use super::validate_task_name;
use super::{CMD_LABEL_MAX_CHARS, short_cmd_label, validate_task_name};
use crate::test_util::with_harness_dir;
use hive_types::Ident;
#[test]
fn short_cmd_label_short_single_line_is_unchanged() {
assert_eq!(short_cmd_label("echo hi"), "echo hi");
}
#[test]
fn short_cmd_label_trims_and_skips_leading_blank_lines() {
assert_eq!(short_cmd_label("\n\n echo hi \nmore stuff"), "echo hi…");
}
#[test]
fn short_cmd_label_flags_multiline_even_when_first_line_is_short() {
// The whole point: a short first line followed by a long heredoc
// body must still read as truncated, not as the complete command.
let cmd = "cat > script.sh <<'SH'\n#!/usr/bin/env bash\necho hi\nSH\n";
let label = short_cmd_label(cmd);
assert_eq!(label, "cat > script.sh <<'SH'…");
}
#[test]
fn short_cmd_label_char_truncates_a_long_single_line() {
let long = "x".repeat(CMD_LABEL_MAX_CHARS + 50);
let label = short_cmd_label(&long);
assert_eq!(label.chars().count(), CMD_LABEL_MAX_CHARS + 1); // +1 for the `…`
assert!(label.ends_with('…'));
}
#[test]
fn short_cmd_label_handles_empty_command() {
assert_eq!(short_cmd_label(""), "");
assert_eq!(short_cmd_label(" \n \n"), "");
}
#[test]
fn accepts_ident_names() {
for ok in ["build", "ci-check", "t1", "task-123", "abc"] {