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:
parent
c5e03c6d01
commit
70c86d2c73
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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]")) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue