fix(hermes): strip ANSI escape codes from terminal output in UI parsers (#8731)

## 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
This commit is contained in:
neerajdad123-byte 2026-07-07 00:37:55 +05:30 committed by GitHub
parent c5e03c6d01
commit 70c86d2c73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 123 additions and 8 deletions

View File

@ -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 };

View File

@ -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 }];
}

View File

@ -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);
});
});

View File

@ -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 ────────────────────────────────────────────

View File

@ -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]")) {