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
4 changes: 4 additions & 0 deletions changelog.d/claude-bash-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added
- Chat shows a Claude Code bash-mode command (`!`) as one card: the command, then what it
printed, with the output of a failed command in red. Before, Chat showed the raw
`<bash-input>` and `<bash-stdout>` tags in two messages.
42 changes: 42 additions & 0 deletions server/api.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,48 @@ describe("native tool call id and state on GET /api/pane/conversation", () => {
}, 20_000);
});

describe("a Claude bash-mode command on GET /api/pane/conversation", () => {
it("answers it as one shell part, and leaves out the streams Claude Code has not recorded yet", async () => {
const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-bash-mode-contract-"));
const cwd = realpathSync(root);
const originalHome = process.env["HOME"];
const id = "3b7e9c2a-5d14-4f8b-9a63-0c2e7f1d8b45";
const file = join(cwd, ".claude", "projects", claudeProjectDir(cwd), `${id}.jsonl`);
mkdirSync(join(file, ".."), { recursive: true });
const user = (content: string, second: number) => `${JSON.stringify({ type: "user", timestamp: `2026-10-27T03:00:0${second}.000Z`, message: { role: "user", content } })}\n`;
writeFileSync(file, user('<bash-input>echo "Hello"</bash-input>', 0) + user("<bash-stdout>Hello</bash-stdout><bash-stderr></bash-stderr>", 1)
+ user("<bash-input>ls missing</bash-input>", 2) + user("<bash-stdout></bash-stdout><bash-stderr>ls: missing: No such file or directory</bash-stderr>", 3)
+ user("<bash-input>sleep 60</bash-input>", 4));
let app: ReturnType<typeof createServer> | undefined;
let workspace: string | undefined;
try {
process.env["HOME"] = cwd;
forgetTranscriptState();
const created = await workspaceCreate({ cwd, label: "herdr-web-ui-test-bash-mode" });
workspace = created.workspace.workspace_id;
const paneId = created.root_pane.pane_id;
await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "claude", state: "idle" });
await herdrRpc("pane.report_agent_session", { pane_id: paneId, source: "herdr:claude", agent: "claude", seq: Date.now() * 1000, agent_session_id: id });
app = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(cwd, "state") });
const response = await fetch(`http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(paneId)}`);
expect(response.status).toBe(200);
const body = await response.json() as { source: string; turns: { role: string; parts: unknown[] }[] };
expect(body.source).toBe("claude-transcript");
expect(body.turns.map((turn) => [turn.role, turn.parts])).toEqual([
["user", [{ kind: "shell", command: 'echo "Hello"', stdout: "Hello", stderr: "" }]],
["user", [{ kind: "shell", command: "ls missing", stdout: "", stderr: "ls: missing: No such file or directory" }]],
// no output entry yet: the JSON has neither stream, not empty ones
["user", [{ kind: "shell", command: "sleep 60" }]],
]);
} finally {
app?.stop();
if (workspace !== undefined) await workspaceClose(workspace);
if (originalHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = originalHome;
forgetTranscriptState(); rmSync(root, { recursive: true, force: true });
}
}, 20_000);
});

describe("usage API", () => {
it("returns OpenCode Go windows without exposing its credential", async () => {
const usageState = mkdtempSync(join(tmpdir(), "herdr-opencode-contract-"));
Expand Down
87 changes: 71 additions & 16 deletions server/claude-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { trimOutput } from "./tool-output.ts";
import { base64Size, inlineImageType, MAX_IMAGE_BYTES } from "./codex-images.ts";

/** Slash-command and bookkeeping entries Claude logs as user turns — not conversations. */
export function isCommandEntry(text: string): boolean {
export function isCommandEntry(text: string, entry: { origin?: unknown; permissionMode?: unknown }): boolean {
if (claudeCommandEcho(text)) return false;
return text.startsWith("<command-") || text.startsWith("<local-command") || text.startsWith("<task-");
return text.startsWith("<command-") || text.startsWith("<local-command") || text.startsWith("<task-")
// bash-mode output, only as the whole entry: the parser joins it to its command
|| (!("origin" in entry) && !("permissionMode" in entry) && text.trimStart().startsWith("<bash-std") && taggedStreams(text, "bash") !== null);
}

/**
Expand All @@ -20,36 +22,72 @@ export function isCommandEntry(text: string): boolean {
const LOCAL_COMMAND_MAX_CHARS = 4000;

function localCommandOutput(content: unknown): string {
if (typeof content !== "string") return "";
return capNotice((taggedStreams(content, "local-command") ?? []).map(({ text }) => text.trim()).filter((text) => text.length > 0).join("\n"));
}

/** Bash payloads are opaque: only an unambiguous boundary identifies stderr. */
function bashStreams(content: string): { stream: "stdout" | "stderr"; text: string }[] | null {
const envelope = content.trim();
if (envelope.startsWith("<bash-stdout>") && envelope.endsWith("</bash-stderr>")) {
const inner = envelope.slice("<bash-stdout>".length, -"</bash-stderr>".length);
const candidates = [...inner.matchAll(/<\/bash-stdout>\s*<bash-stderr>/g)];
const first = candidates[0];
if (first === undefined) return null;
if (candidates.length === 1) {
return [
{ stream: "stdout", text: stripTerminalControls(inner.slice(0, first.index)).trimEnd() },
{ stream: "stderr", text: stripTerminalControls(inner.slice(first.index + first[0].length)).trimEnd() },
];
}
// Multiple boundaries never establish stderr: retain every inner character verbatim.
return [{ stream: "stdout", text: stripTerminalControls(inner) }];
}
// Older records can carry a single stream, but not reordered or partial envelopes.
const single = /^<bash-(stdout|stderr)>([\s\S]*)<\/bash-\1>$/.exec(envelope);
const stream = single?.[1], text = single?.[2];
return (stream === "stdout" || stream === "stderr") && text !== undefined
? [{ stream, text: stripTerminalControls(text).trimEnd() }] : null;
}

/**
* The streams a whole entry holds, `<{tag}-stdout>…</{tag}-stdout>` and `<{tag}-stderr>…`, in order and with
* terminal controls stripped; ambiguous bash output stays verbatim in stdout. Null when the entry
* holds anything else.
*/
function taggedStreams(content: unknown, tag: "local-command" | "bash"): { stream: "stdout" | "stderr"; text: string }[] | null {
if (typeof content !== "string") return null;
if (tag === "bash") return bashStreams(content);
// the whole entry is the output, one stream after another: a tag quoted inside an echo's
// arguments is not an answer
const streams: string[] = [];
const streams: { stream: "stdout" | "stderr"; text: string }[] = [];
let at = 0;
for (;;) {
while (at < content.length && /\s/.test(content[at]!)) at++;
if (at === content.length) break;
const stream = content.startsWith("<local-command-stdout>", at) ? "stdout" : content.startsWith("<local-command-stderr>", at) ? "stderr" : null;
if (stream === null) return "";
const start = at + `<local-command-${stream}>`.length;
const stream = content.startsWith(`<${tag}-stdout>`, at) ? "stdout" : content.startsWith(`<${tag}-stderr>`, at) ? "stderr" : null;
if (stream === null) return null;
const start = at + `<${tag}-${stream}>`.length;
// a closing tag the output prints itself ends nothing: the stream's own one is at the end of
// the entry or before the next stream. Each candidate looks only at the whitespace after it.
const close = `</local-command-${stream}>`;
const close = `</${tag}-${stream}>`;
let end = content.indexOf(close, start);
let next = 0;
while (end !== -1) {
next = end + close.length;
while (next < content.length && /\s/.test(content[next]!)) next++;
if (next === content.length || content.startsWith("<local-command-stdout>", next) || content.startsWith("<local-command-stderr>", next)) break;
if (next === content.length || content.startsWith(`<${tag}-stdout>`, next) || content.startsWith(`<${tag}-stderr>`, next)) break;
end = content.indexOf(close, end + close.length);
}
if (end === -1) return "";
const text = stripTerminalControls(content.slice(start, end)).trim();
if (text.length > 0) streams.push(text);
if (end === -1) return null;
streams.push({ stream, text: stripTerminalControls(content.slice(start, end)) });
at = next;
}
return capNotice(streams.join("\n"));
return streams;
}

/** The command of a bash-mode entry (`!` in Claude Code): the whole entry is `<bash-input>…</bash-input>`. */
const BASH_INPUT = /^\s*<bash-input>([\s\S]*)<\/bash-input>\s*$/;

/** Lines of one notice Claude Code writes as separate entries, a moment apart. */
const NOTICE_JOIN_MS = 1000;

Expand Down Expand Up @@ -146,6 +184,8 @@ export interface TranscriptEntry {
parentUuid?: string | null;
isMeta?: boolean;
isCompactSummary?: boolean;
origin?: unknown;
permissionMode?: unknown;
/** "updates": Claude Code shows the entry's thinking text as a progress message, not as reasoning */
thinkingDisplay?: unknown;
message?: { role?: string; content?: unknown; stop_reason?: unknown };
Expand Down Expand Up @@ -308,7 +348,7 @@ function claudeTranscriptParser(options: ClaudeTranscriptOptions, state: ClaudeP
const queued = entry.type === "attachment" ? entry.attachment : undefined;
if (queued?.commandMode === "task-notification" && typeof queued.prompt === "string" && notified(queued.prompt, entry.timestamp ?? null)) return;
if (queued?.type === "queued_command" && queued.commandMode === "prompt" && queued.origin?.kind === "human") {
if (typeof queued.prompt === "string" && queued.prompt.trim() && !isCommandEntry(queued.prompt.trim())) {
if (typeof queued.prompt === "string" && queued.prompt.trim() && !isCommandEntry(queued.prompt.trim(), queued)) {
turns.push({ role: "user", ts: entry.timestamp ?? null, parts: [{ kind: "text", text: unwrapPastes(queued.prompt) }] });
}
return;
Expand Down Expand Up @@ -351,7 +391,22 @@ function claudeTranscriptParser(options: ClaudeTranscriptOptions, state: ClaudeP
pushPrompt({ role: "user", ts: entry.timestamp ?? null, parts: [{ kind: "text", text: invocation.text }, { kind: "skill", skill: invocation.skill }] }, entry);
return;
}
if (isCommandEntry(content)) return;
// bash mode: the command opens a turn like a prompt, and the entry after it holds what it printed
const own = !("origin" in entry) && !("permissionMode" in entry);
const command = own ? BASH_INPUT.exec(content)?.[1] : undefined;
if (command !== undefined) {
pushPrompt({ role: "user", ts: entry.timestamp ?? null, parts: [{ kind: "shell", command }] }, entry);
return;
}
const printed = own ? taggedStreams(content, "bash") : null;
const shell = state.lastPrompt?.turn.parts[0];
if (printed !== null && shell?.kind === "shell" && shell.stdout === undefined) {
const of = (stream: "stdout" | "stderr") => capNotice(printed.filter((part) => part.stream === stream).map(({ text }) => text).join("\n"));
shell.stdout = of("stdout");
shell.stderr = of("stderr");
}
if (printed !== null) return;
if (isCommandEntry(content, entry)) return;
pushPrompt({ role: "user", ts: entry.timestamp ?? null, parts: [{ kind: "text", text: unwrapPastes(content) }] }, entry);
return;
}
Expand All @@ -368,7 +423,7 @@ function claudeTranscriptParser(options: ClaudeTranscriptOptions, state: ClaudeP
invocations.push({ kind: "skill", skill: invocation.skill });
return [invocation.text];
}
return part.type === "text" && typeof part.text === "string" && !isCommandEntry(part.text.trim()) ? [part.text] : [];
return part.type === "text" && typeof part.text === "string" && !isCommandEntry(part.text.trim(), entry) ? [part.text] : [];
}).join("\n");
for (const [index, block] of content.entries()) {
if (typeof block !== "object" || block === null) continue;
Expand Down
Loading
Loading