From 70c86d2c73c2796506d90c296668dfdb024cc04b Mon Sep 17 00:00:00 2001 From: neerajdad123-byte Date: Tue, 7 Jul 2026 00:37:55 +0530 Subject: [PATCH] fix(hermes): strip ANSI escape codes from terminal output in UI parsers (#8731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Hermes adapter produces terminal output with ANSI color codes on stdout > - These escape sequences flow through the UI parsers untouched and render as raw garbage text > - This PR adds ANSI stripping at the entry point of all four Hermes parse-stdout entry points > - The same regex is already proven in claude-local adapter > - The benefit is clean, readable terminal output for Hermes agents ## Linked Issues or Issue Description No existing issue. This is a bug report: **What happened** Hermes terminal output displayed ANSI color codes as raw text in the Paperclip UI, making agent output unreadable. **Expected behavior** Terminal output in run transcripts should be clean text without invisible control characters. **Steps to reproduce** 1. Connect a Hermes agent to Paperclip 2. Create and assign a task to the agent 3. View the run transcript — ANSI escape codes appear as raw garbage **Paperclip version or commit** e6407b322 (upstream master) **Deployment mode** local_trusted ## What Changed - Added `stripAnsi()` function using the same regex pattern from claude-local adapter (quota.ts) — strips CSI and OSC sequences - Applied at entry point of `parseHermesStdoutLine` in hermes_local (TS + CJS) - Applied at entry point of `parseHermesGatewayStdoutLine` in hermes_gateway (TS + CJS) - CJS files keep the function inline since the dynamic parser sandbox has no module loader - 5 files changed, +123/-8 lines ## Verification - Smoke tested with real ANSI patterns from Hermes output — all samples pass - `pnpm --filter @paperclipai/hermes-paperclip-adapter exec vitest run src/ui/parse-stdout.test.ts` — 9 passed - TypeScript compiles clean for both hermes and hermes-gateway packages - Adapter tests pass (5/6, 1 pre-existing Windows CI failure unrelated) - Live tested on running Paperclip instance — ANSI codes no longer appear in transcripts ## Risks Low risk. Only affects Hermes parser output. Regex already proven in claude-local adapter. No logic changes to parse behavior — only strips invisible control characters before parsing. ## Model Used DeepSeek V4 Pro — reasoning mode, tool use ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapters/hermes/gateway-ui-parser.cjs | 13 ++- .../hermes/src/gateway/ui/parse-stdout.ts | 17 +++- .../hermes/src/ui/parse-stdout.test.ts | 81 +++++++++++++++++++ .../adapters/hermes/src/ui/parse-stdout.ts | 12 ++- packages/adapters/hermes/ui-parser.cjs | 8 +- 5 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 packages/adapters/hermes/src/ui/parse-stdout.test.ts diff --git a/packages/adapters/hermes/gateway-ui-parser.cjs b/packages/adapters/hermes/gateway-ui-parser.cjs index 1cba319c8d..4654dbdef8 100644 --- a/packages/adapters/hermes/gateway-ui-parser.cjs +++ b/packages/adapters/hermes/gateway-ui-parser.cjs @@ -1,5 +1,11 @@ "use strict"; +function stripAnsi(text) { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + function safeJsonParse(text) { try { return JSON.parse(text); @@ -18,7 +24,8 @@ function asString(value) { } function parseStdoutLine(line, ts) { - const trimmed = line.trim(); + const cleaned = stripAnsi(line); + const trimmed = cleaned.trim(); if (!trimmed) return []; const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s); @@ -27,7 +34,7 @@ function parseStdoutLine(line, ts) { const data = asRecord(safeJsonParse(eventMatch[3])); if (eventName === "message.delta") { const delta = asString(data && data.delta) || asString(data && data.text_delta); - return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : []; + return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : []; } if (eventName === "run.failed" || eventName === "run.error") { const message = asString(data && data.error) || asString(data && data.message) || "Hermes run failed"; @@ -43,7 +50,7 @@ function parseStdoutLine(line, ts) { return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: cleaned }]; } module.exports = { parseStdoutLine }; diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts index df9db752dd..2837bea15e 100644 --- a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts @@ -1,5 +1,15 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +/** + * Strip ANSI escape sequences (CSI, OSC) from terminal text. + * Same pattern used in claude-local adapter quota.ts. + */ +function stripAnsi(text: string): string { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + function safeJsonParse(text: string): unknown { try { return JSON.parse(text); @@ -18,7 +28,8 @@ function asString(value: unknown): string { } export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { - const trimmed = line.trim(); + const cleaned = stripAnsi(line); + const trimmed = cleaned.trim(); if (!trimmed) return []; const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s); @@ -27,7 +38,7 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri const data = asRecord(safeJsonParse(eventMatch[3])); if (eventName === "message.delta") { const delta = asString(data?.delta) || asString(data?.text_delta); - return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : []; + return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : []; } if (eventName === "run.failed" || eventName === "run.error") { const message = asString(data?.error) || asString(data?.message) || "Hermes run failed"; @@ -43,5 +54,5 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: cleaned }]; } diff --git a/packages/adapters/hermes/src/ui/parse-stdout.test.ts b/packages/adapters/hermes/src/ui/parse-stdout.test.ts new file mode 100644 index 0000000000..8e25cf8cd7 --- /dev/null +++ b/packages/adapters/hermes/src/ui/parse-stdout.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { parseHermesStdoutLine } from "./parse-stdout.js"; + +const TS = "2026-06-29T12:00:00.000Z"; + +describe("parseHermesStdoutLine — ANSI stripping", () => { + it("strips 24-bit foreground + background color CSI sequences", () => { + const result = parseHermesStdoutLine( + "\x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"POST\", \"/api/issues/d7b08cc5/comments\",\x1b[0m", + TS, + ); + expect(result.length).toBeGreaterThan(0); + for (const entry of result) { + for (const v of Object.values(entry)) { + if (typeof v === "string") { + expect(v).not.toMatch(/\x1b\[/); + } + } + } + }); + + it("strips bold yellow CSI sequence from Hermes header", () => { + const result = parseHermesStdoutLine("\x1b[1;38;2;255;215;0m- Hermes\x1b[0m", TS); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "- Hermes"); + }); + + it("strips light text CSI sequence", () => { + const result = parseHermesStdoutLine( + "\x1b[38;2;255;248;220mAll done. Now let me verify.\x1b[0m", + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "All done. Now let me verify."); + }); + + it("passes through clean text unchanged", () => { + const result = parseHermesStdoutLine("Normal text without ANSI", TS); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "Normal text without ANSI"); + }); + + it("strips multiple CSI sequences on a single line", () => { + const result = parseHermesStdoutLine( + "\x1b[38;2;255;255;255;48;2;19;87;20m+ \"priority\": \"highest\",\x1b[0m \x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"PATCH\", ...\x1b[0m", + TS, + ); + expect(result.length).toBeGreaterThan(0); + for (const entry of result) { + for (const v of Object.values(entry)) { + if (typeof v === "string") { + expect(v).not.toMatch(/\x1b\[/); + } + } + } + }); + + it("still parses tool completion lines correctly after stripping", () => { + const result = parseHermesStdoutLine("\u250a \u{1f50d} search \"pattern\" 0.5s", TS); + expect(result.length).toBeGreaterThanOrEqual(2); + const toolCall = result.find((e) => e.kind === "tool_call"); + expect(toolCall?.name).toBe("search"); + }); + + it("still parses shell tool lines correctly after stripping", () => { + const result = parseHermesStdoutLine("\u250a $ ls -la 0.3s", TS); + const toolCall = result.find((e) => e.kind === "tool_call"); + expect(toolCall?.name).toBe("shell"); + }); + + it("strips OSC title sequences", () => { + const result = parseHermesStdoutLine("\x1b]0;Terminal Title\x07Actual content", TS); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "Actual content"); + }); + + it("handles empty lines after ANSI stripping", () => { + const result = parseHermesStdoutLine("\x1b[0m", TS); + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/adapters/hermes/src/ui/parse-stdout.ts b/packages/adapters/hermes/src/ui/parse-stdout.ts index 6e48b40ed4..1589843ed9 100644 --- a/packages/adapters/hermes/src/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/ui/parse-stdout.ts @@ -1,3 +1,13 @@ +/** + * Strip ANSI escape sequences (CSI, OSC) from terminal text. + * Same pattern used in claude-local adapter quota.ts. + */ +function stripAnsi(text: string): string { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + /** * Parse Hermes Agent stdout into TranscriptEntry objects for the Paperclip UI. * @@ -184,7 +194,7 @@ export function parseHermesStdoutLine( line: string, ts: string, ): TranscriptEntry[] { - const trimmed = line.trim(); + const trimmed = stripAnsi(line).trim(); if (!trimmed) return []; // ── System/adapter messages ──────────────────────────────────────────── diff --git a/packages/adapters/hermes/ui-parser.cjs b/packages/adapters/hermes/ui-parser.cjs index 618c37606c..48d1ac9062 100644 --- a/packages/adapters/hermes/ui-parser.cjs +++ b/packages/adapters/hermes/ui-parser.cjs @@ -1,5 +1,11 @@ "use strict"; +function stripAnsi(text) { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + const TOOL_OUTPUT_PREFIX = "\u250a"; function stripKaomoji(text) { @@ -114,7 +120,7 @@ function isThinkingLine(line) { } function parseStdoutLine(line, ts) { - const trimmed = line.trim(); + const trimmed = stripAnsi(line).trim(); if (!trimmed) return []; if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) {