From 28de7cc420ff3bdf70928f23808c4a7c0cad18cf Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 20 Mar 2026 01:38:11 -0700 Subject: [PATCH] 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). --- scripts/hooks/session-end.js | 6 ++-- scripts/hooks/session-start.js | 4 ++- scripts/lib/utils.js | 21 ++++++++++++ tests/lib/utils.test.js | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 301ced97..af378001 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -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)); } } diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 1a044f31..a724e1ea 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -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)}`); } } diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 41e04e57..a3086258 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -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[ … (colors, cursor movement, erase, etc.) + * - OSC sequences: \x1b] … BEL/ST (window titles, hyperlinks) + * - Charset selection: \x1b(B + * - Bare ESC + single letter: \x1b (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, diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index a71590ff..b7a26ead 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -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}`);