fix: strip ANSI escape codes from session persistence hooks (#642) (#684)

Windows terminals emit control sequences (cursor movement, screen
clearing) that leaked into session.tmp files and were injected
verbatim into Claude's context on the next session start.

Add a comprehensive stripAnsi() to utils.js that handles CSI, OSC,
charset selection, and bare ESC sequences. Apply it in session-end.js
(when extracting user messages from the transcript) and in
session-start.js (safety net before injecting session content).
This commit is contained in:
Affaan Mustafa
2026-03-20 01:38:11 -07:00
committed by GitHub
parent 9a478ad676
commit 28de7cc420
4 changed files with 87 additions and 3 deletions

View File

@@ -21,6 +21,7 @@ const {
readFile,
writeFile,
runCommand,
stripAnsi,
log
} = require('../lib/utils');
@@ -58,8 +59,9 @@ function extractSessionSummary(transcriptPath) {
: Array.isArray(rawContent)
? rawContent.map(c => (c && c.text) || '').join(' ')
: '';
if (text.trim()) {
userMessages.push(text.trim().slice(0, 200));
const cleaned = stripAnsi(text).trim();
if (cleaned) {
userMessages.push(cleaned.slice(0, 200));
}
}

View File

@@ -15,6 +15,7 @@ const {
findFiles,
ensureDir,
readFile,
stripAnsi,
log,
output
} = require('../lib/utils');
@@ -42,7 +43,8 @@ async function main() {
const content = readFile(latest.path);
if (content && !content.includes('[Session context goes here]')) {
// Only inject if the session has actual content (not the blank template)
output(`Previous session summary:\n${content}`);
// Strip ANSI escape codes that may have leaked from terminal output (#642)
output(`Previous session summary:\n${stripAnsi(content)}`);
}
}

View File

@@ -464,6 +464,24 @@ function countInFile(filePath, pattern) {
return matches ? matches.length : 0;
}
/**
* Strip all ANSI escape sequences from a string.
*
* Handles:
* - CSI sequences: \x1b[ … <letter> (colors, cursor movement, erase, etc.)
* - OSC sequences: \x1b] … BEL/ST (window titles, hyperlinks)
* - Charset selection: \x1b(B
* - Bare ESC + single letter: \x1b <letter> (e.g. \x1bM for reverse index)
*
* @param {string} str - Input string possibly containing ANSI codes
* @returns {string} Cleaned string with all escape sequences removed
*/
function stripAnsi(str) {
if (typeof str !== 'string') return '';
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|\([A-Z]|[A-Z])/g, '');
}
/**
* Search for pattern in file and return matching lines with line numbers
*/
@@ -530,6 +548,9 @@ module.exports = {
countInFile,
grepFile,
// String sanitisation
stripAnsi,
// Hook I/O
readStdinJson,
log,

View File

@@ -2424,6 +2424,65 @@ function runTests() {
}
})) passed++; else failed++;
// ─── stripAnsi ───
console.log('\nstripAnsi:');
if (test('strips SGR color codes (\\x1b[...m)', () => {
assert.strictEqual(utils.stripAnsi('\x1b[31mRed text\x1b[0m'), 'Red text');
assert.strictEqual(utils.stripAnsi('\x1b[1;36mBold cyan\x1b[0m'), 'Bold cyan');
})) passed++; else failed++;
if (test('strips cursor movement sequences (\\x1b[H, \\x1b[2J, \\x1b[3J)', () => {
// These are the exact sequences reported in issue #642
assert.strictEqual(utils.stripAnsi('\x1b[H\x1b[2J\x1b[3JHello'), 'Hello');
assert.strictEqual(utils.stripAnsi('before\x1b[Hafter'), 'beforeafter');
})) passed++; else failed++;
if (test('strips cursor position sequences (\\x1b[row;colH)', () => {
assert.strictEqual(utils.stripAnsi('\x1b[5;10Hplaced'), 'placed');
})) passed++; else failed++;
if (test('strips erase line sequences (\\x1b[K, \\x1b[2K)', () => {
assert.strictEqual(utils.stripAnsi('line\x1b[Kend'), 'lineend');
assert.strictEqual(utils.stripAnsi('line\x1b[2Kend'), 'lineend');
})) passed++; else failed++;
if (test('strips OSC sequences (window title, hyperlinks)', () => {
// OSC terminated by BEL (\x07)
assert.strictEqual(utils.stripAnsi('\x1b]0;My Title\x07content'), 'content');
// OSC terminated by ST (\x1b\\)
assert.strictEqual(utils.stripAnsi('\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\'), 'link');
})) passed++; else failed++;
if (test('strips charset selection (\\x1b(B)', () => {
assert.strictEqual(utils.stripAnsi('\x1b(Bnormal'), 'normal');
})) passed++; else failed++;
if (test('strips bare ESC + letter (\\x1bM reverse index)', () => {
assert.strictEqual(utils.stripAnsi('line\x1bMup'), 'lineup');
})) passed++; else failed++;
if (test('handles mixed ANSI sequences in one string', () => {
const input = '\x1b[H\x1b[2J\x1b[1;36mSession\x1b[0m summary\x1b[K';
assert.strictEqual(utils.stripAnsi(input), 'Session summary');
})) passed++; else failed++;
if (test('returns empty string for non-string input', () => {
assert.strictEqual(utils.stripAnsi(null), '');
assert.strictEqual(utils.stripAnsi(undefined), '');
assert.strictEqual(utils.stripAnsi(42), '');
})) passed++; else failed++;
if (test('preserves string with no ANSI codes', () => {
assert.strictEqual(utils.stripAnsi('plain text'), 'plain text');
assert.strictEqual(utils.stripAnsi(''), '');
})) passed++; else failed++;
if (test('handles CSI with question mark parameter (DEC private modes)', () => {
// e.g. \x1b[?25h (show cursor), \x1b[?25l (hide cursor)
assert.strictEqual(utils.stripAnsi('\x1b[?25hvisible\x1b[?25l'), 'visible');
})) passed++; else failed++;
// Summary
console.log('\n=== Test Results ===');
console.log(`Passed: ${passed}`);