claudescreenfix-hardwicksoftware 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,226 +2,43 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * PTY wrapper for Claude Code terminal fix
5
+ * wrapper script - runs claude with the terminal fix loaded
6
6
  *
7
- * Since Claude's binary is a Node.js SEA (ELF), we can't inject via --import.
8
- * Instead, we spawn claude in a pseudo-terminal and intercept stdout to inject
9
- * scrollback clears and handle SIGWINCH debouncing.
7
+ * finds your claude binary and runs it with our fix injected
8
+ * no manual setup needed, just run claude-fixed instead of claude
10
9
  */
11
10
 
12
- const { spawn } = require('child_process');
11
+ const { spawn, execSync } = require('child_process');
13
12
  const path = require('path');
14
13
  const fs = require('fs');
15
14
 
16
- // Terminal escape codes
17
- const CLEAR_SCROLLBACK = '\x1b[3J';
18
- const CURSOR_SAVE = '\x1b[s';
19
- const CURSOR_RESTORE = '\x1b[u';
20
- const CLEAR_SCREEN = '\x1b[2J';
21
- const HOME_CURSOR = '\x1b[H';
15
+ // find the loader path
16
+ const loaderPath = path.join(__dirname, '..', 'loader.cjs');
22
17
 
23
- // Config
24
- const config = {
25
- resizeDebounceMs: 150,
26
- periodicClearMs: 60000,
27
- clearAfterRenders: 500,
28
- typingCooldownMs: 500,
29
- maxLineCount: 120,
30
- debug: process.env.CLAUDE_TERMINAL_FIX_DEBUG === '1',
31
- disabled: process.env.CLAUDE_TERMINAL_FIX_DISABLED === '1'
32
- };
33
-
34
- // State
35
- let renderCount = 0;
36
- let lineCount = 0;
37
- let lastTypingTime = 0;
38
- let lastResizeTime = 0;
39
- let resizeTimeout = null;
40
-
41
- function log(...args) {
42
- if (config.debug) {
43
- process.stderr.write('[terminal-fix] ' + args.join(' ') + '\n');
44
- }
45
- }
46
-
47
- function isTypingActive() {
48
- return (Date.now() - lastTypingTime) < config.typingCooldownMs;
49
- }
50
-
51
- // Find the claude binary
52
- function findClaude() {
53
- const possiblePaths = [
54
- path.join(process.env.HOME || '', '.local/bin/claude'),
55
- '/usr/local/bin/claude',
56
- '/usr/bin/claude'
57
- ];
58
-
59
- for (const p of possiblePaths) {
60
- if (fs.existsSync(p)) {
61
- return p;
62
- }
63
- }
64
-
65
- // Try which
66
- try {
67
- const { execSync } = require('child_process');
68
- return execSync('which claude', { encoding: 'utf8' }).trim();
69
- } catch (e) {
70
- return null;
71
- }
18
+ if (!fs.existsSync(loaderPath)) {
19
+ console.error('loader not found at ' + loaderPath);
20
+ process.exit(1);
72
21
  }
73
22
 
74
- // Process output chunk, injecting clears as needed
75
- function processOutput(chunk) {
76
- if (config.disabled) return chunk;
77
-
78
- let output = chunk;
79
- const str = chunk.toString();
80
-
81
- renderCount++;
82
-
83
- // Count newlines
84
- const newlines = (str.match(/\n/g) || []).length;
85
- lineCount += newlines;
86
-
87
- // Line limit exceeded - force clear
88
- if (lineCount > config.maxLineCount) {
89
- log('line limit exceeded (' + lineCount + '), forcing clear');
90
- lineCount = 0;
91
- output = CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE + output;
92
- }
93
-
94
- // Screen clear detected - piggyback our scrollback clear
95
- if (str.includes(CLEAR_SCREEN) || str.includes(HOME_CURSOR)) {
96
- lineCount = 0;
97
- if (config.clearAfterRenders > 0 && renderCount >= config.clearAfterRenders) {
98
- if (!isTypingActive()) {
99
- log('clearing after ' + renderCount + ' renders');
100
- renderCount = 0;
101
- output = CLEAR_SCROLLBACK + output;
102
- }
103
- }
104
- }
105
-
106
- // /clear command - nuke everything
107
- if (str.includes('Conversation cleared') || str.includes('Chat cleared')) {
108
- log('/clear detected');
109
- lineCount = 0;
110
- output = CLEAR_SCROLLBACK + output;
111
- }
112
-
113
- return output;
23
+ // find claude binary
24
+ let claudeBin;
25
+ try {
26
+ claudeBin = execSync('which claude', { encoding: 'utf8' }).trim();
27
+ } catch (e) {
28
+ console.error('claude not found in PATH - make sure its installed');
29
+ process.exit(1);
114
30
  }
115
31
 
116
- // Main
117
- async function main() {
118
- if (config.disabled) {
119
- log('disabled via env');
120
- }
121
-
122
- const claudePath = findClaude();
123
- if (!claudePath) {
124
- console.error('claude not found in PATH');
125
- process.exit(1);
126
- }
127
-
128
- log('using claude at: ' + claudePath);
129
- log('fix enabled, config:', JSON.stringify(config));
130
-
131
- // Try to use node-pty for proper PTY support
132
- let pty;
133
- try {
134
- pty = require('node-pty');
135
- } catch (e) {
136
- // Fall back to basic spawn with pipe
137
- log('node-pty not available, using basic spawn');
138
- pty = null;
139
- }
140
-
141
- if (pty && process.stdin.isTTY) {
142
- // PTY mode - full terminal emulation
143
- const term = pty.spawn(claudePath, process.argv.slice(2), {
144
- name: process.env.TERM || 'xterm-256color',
145
- cols: process.stdout.columns || 80,
146
- rows: process.stdout.rows || 24,
147
- cwd: process.cwd(),
148
- env: process.env
149
- });
150
-
151
- // Handle resize with debounce
152
- process.stdout.on('resize', () => {
153
- const now = Date.now();
154
- if (resizeTimeout) clearTimeout(resizeTimeout);
155
-
156
- if (now - lastResizeTime < config.resizeDebounceMs) {
157
- resizeTimeout = setTimeout(() => {
158
- log('debounced resize');
159
- term.resize(process.stdout.columns, process.stdout.rows);
160
- }, config.resizeDebounceMs);
161
- } else {
162
- term.resize(process.stdout.columns, process.stdout.rows);
163
- }
164
- lastResizeTime = now;
165
- });
166
-
167
- // Track typing
168
- process.stdin.on('data', (data) => {
169
- lastTypingTime = Date.now();
170
- term.write(data);
171
- });
172
-
173
- // Process output
174
- term.onData((data) => {
175
- const processed = processOutput(data);
176
- process.stdout.write(processed);
177
- });
178
-
179
- term.onExit(({ exitCode }) => {
180
- process.exit(exitCode);
181
- });
182
-
183
- // Raw mode for proper terminal handling
184
- if (process.stdin.setRawMode) {
185
- process.stdin.setRawMode(true);
186
- }
187
- process.stdin.resume();
188
-
189
- // Periodic clear
190
- if (config.periodicClearMs > 0) {
191
- setInterval(() => {
192
- if (!isTypingActive()) {
193
- log('periodic clear');
194
- process.stdout.write(CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE);
195
- }
196
- }, config.periodicClearMs);
197
- }
198
-
199
- } else {
200
- // Basic mode - just spawn and pipe (limited fix capability)
201
- log('basic mode (no PTY)');
202
-
203
- const child = spawn(claudePath, process.argv.slice(2), {
204
- stdio: ['inherit', 'pipe', 'inherit'],
205
- env: process.env
206
- });
207
-
208
- child.stdout.on('data', (data) => {
209
- const processed = processOutput(data);
210
- process.stdout.write(processed);
211
- });
212
-
213
- child.on('exit', (code) => {
214
- process.exit(code || 0);
215
- });
32
+ // run claude with our fix loaded via NODE_OPTIONS
33
+ const env = Object.assign({}, process.env, {
34
+ NODE_OPTIONS: '--require ' + loaderPath + ' ' + (process.env.NODE_OPTIONS || '')
35
+ });
216
36
 
217
- // Track typing via stdin
218
- process.stdin.on('data', () => {
219
- lastTypingTime = Date.now();
220
- });
221
- }
222
- }
37
+ const child = spawn(claudeBin, process.argv.slice(2), {
38
+ stdio: 'inherit',
39
+ env: env
40
+ });
223
41
 
224
- main().catch(err => {
225
- console.error('terminal fix error:', err.message);
226
- process.exit(1);
42
+ child.on('exit', (code) => {
43
+ process.exit(code || 0);
227
44
  });
package/index.cjs CHANGED
@@ -4,42 +4,47 @@
4
4
  * claudescreenfix-hardwicksoftware - stops the scroll glitch from cooking your terminal
5
5
  *
6
6
  * the problem:
7
- * claude code uses ink (react for terminals) and it doesn't clear scrollback
8
- * so after like 30 min your terminal's got thousands of lines in the buffer
7
+ * claude code uses ink (react for terminals) and it dont clear scrollback
8
+ * so after like 30 min your terminal got thousands of lines in the buffer
9
9
  * every re-render touches ALL of em - O(n) where n keeps growing
10
10
  * resize events fire with no debounce so tmux/screen users get cooked
11
11
  *
12
12
  * what we do:
13
13
  * - hook stdout.write to inject scrollback clears periodically
14
- * - debounce SIGWINCH so resize ain't thrashing
14
+ * - debounce SIGWINCH so resize aint thrashing
15
15
  * - enhance /clear to actually clear scrollback not just the screen
16
16
  *
17
- * v1.0.1: fixed the typing bug where keystrokes got eaten
18
- * - stdin echoes now pass through untouched
19
- * - clears happen async so typing isn't interrupted
17
+ * FIXED v1.0.1: typing issue where stdin echo was being intercepted
18
+ * - now detects stdin echo writes and passes them through unmodified
19
+ * - uses setImmediate for periodic clears to not interrupt typing
20
+ * - tracks "active typing" window to defer clears during input
20
21
  *
21
- * v2.0.0: added glitch detection + 120 line limit
22
- * - actually detects when terminal's fucked instead of just clearing blindly
23
- * - caps output at 120 lines so buffer won't explode
24
- * - can force send enter key to break out of frozen state
22
+ * NEW v2.2.0: headless/VNC mode
23
+ * - auto-detects Xvfb/VNC/headless environments
24
+ * - strips BACKGROUND colors that cause VTE rendering glitches
25
+ * - keeps foreground colors and spinners working perfectly
26
+ * - your Zesting still zests, just no broken color blocks
25
27
  */
26
28
 
29
+ const { execSync } = require('child_process');
30
+
27
31
  const CLEAR_SCROLLBACK = '\x1b[3J';
28
32
  const CURSOR_SAVE = '\x1b[s';
29
33
  const CURSOR_RESTORE = '\x1b[u';
30
34
  const CLEAR_SCREEN = '\x1b[2J';
31
35
  const HOME_CURSOR = '\x1b[H';
32
36
 
33
- // Try to load glitch detector (optional dependency)
34
- let GlitchDetector = null;
35
- let glitchDetector = null;
36
- try {
37
- const detector = require('./glitch-detector.cjs');
38
- GlitchDetector = detector.GlitchDetector;
39
- glitchDetector = detector.getDetector();
40
- } catch (e) {
41
- // Glitch detector not available, continue without it
42
- }
37
+ // regex patterns for ANSI sequences we want to strip in headless mode
38
+ // background colors + inverse video (which swaps FG to BG)
39
+ const ANSI_BG_PATTERNS = [
40
+ /\x1b\[48;5;\d+m/g, // 256-color background: \x1b[48;5;XXXm
41
+ /\x1b\[48;2;\d+;\d+;\d+m/g, // true color background: \x1b[48;2;R;G;Bm
42
+ /\x1b\[4[0-7]m/g, // standard background colors: \x1b[40m - \x1b[47m
43
+ /\x1b\[10[0-7]m/g, // bright background colors: \x1b[100m - \x1b[107m
44
+ /\x1b\[7m/g, // inverse video - swaps FG/BG, causes same glitch
45
+ /\x1b\[27m/g, // inverse off (no-op but clean it up)
46
+ /\x1b\[49m/g, // default background color
47
+ ];
43
48
 
44
49
  // config - tweak these if needed
45
50
  const config = {
@@ -47,10 +52,11 @@ const config = {
47
52
  periodicClearMs: 60000, // clear scrollback every 60s
48
53
  clearAfterRenders: 500, // or after 500 render cycles
49
54
  typingCooldownMs: 500, // wait this long after typing to clear
50
- maxLineCount: 120, // NEW: max terminal lines before forced trim
51
- glitchRecoveryEnabled: true, // NEW: enable automatic glitch recovery
52
55
  debug: process.env.CLAUDE_TERMINAL_FIX_DEBUG === '1',
53
- disabled: process.env.CLAUDE_TERMINAL_FIX_DISABLED === '1'
56
+ disabled: process.env.CLAUDE_TERMINAL_FIX_DISABLED === '1',
57
+ headlessMode: null, // auto-detect, or force with env var
58
+ forceHeadless: process.env.CLAUDE_HEADLESS_MODE === '1',
59
+ forceNoHeadless: process.env.CLAUDE_HEADLESS_MODE === '0',
54
60
  };
55
61
 
56
62
  // state tracking
@@ -62,8 +68,7 @@ let installed = false;
62
68
  let lastTypingTime = 0; // track when user last typed
63
69
  let pendingClear = false; // defer clear if typing active
64
70
  let clearIntervalId = null;
65
- let lineCount = 0; // NEW: track output line count for 120-line limit
66
- let glitchRecoveryInProgress = false; // NEW: prevent recovery loops
71
+ let headlessDetected = null; // cached headless detection result
67
72
 
68
73
  function log(...args) {
69
74
  if (config.debug) {
@@ -72,7 +77,75 @@ function log(...args) {
72
77
  }
73
78
 
74
79
  /**
75
- * check if user's actively typing (within cooldown window)
80
+ * detect if we're running in a headless/VNC/Xvfb environment
81
+ * VTE (xfce4-terminal, gnome-terminal) has rendering bugs on Xvfb
82
+ * where background colors get drawn ON TOP of text instead of behind
83
+ */
84
+ function isHeadless() {
85
+ // use cached result if available
86
+ if (headlessDetected !== null) return headlessDetected;
87
+
88
+ // check env var overrides first
89
+ if (config.forceHeadless) {
90
+ log('headless mode forced ON via env var');
91
+ headlessDetected = true;
92
+ return true;
93
+ }
94
+ if (config.forceNoHeadless) {
95
+ log('headless mode forced OFF via env var');
96
+ headlessDetected = false;
97
+ return false;
98
+ }
99
+
100
+ // no DISPLAY = definitely headless (but probably no terminal anyway)
101
+ const display = process.env.DISPLAY;
102
+ if (!display) {
103
+ headlessDetected = true;
104
+ return true;
105
+ }
106
+
107
+ try {
108
+ // check if Xvfb is the display server
109
+ const xdpyinfo = execSync(`xdpyinfo -display ${display} 2>/dev/null`, { encoding: 'utf8', timeout: 2000 });
110
+ if (xdpyinfo.toLowerCase().includes('xvfb') || xdpyinfo.toLowerCase().includes('virtual')) {
111
+ log('detected Xvfb/virtual display');
112
+ headlessDetected = true;
113
+ return true;
114
+ }
115
+
116
+ // check for x11vnc or other VNC on this display
117
+ const vnc = execSync(`pgrep -a "x11vnc|Xvnc|vncserver" 2>/dev/null || true`, { encoding: 'utf8', timeout: 2000 });
118
+ if (vnc.includes(display) || vnc.includes('x11vnc')) {
119
+ log('detected VNC server');
120
+ headlessDetected = true;
121
+ return true;
122
+ }
123
+ } catch (e) {
124
+ // if we can't detect, assume not headless
125
+ log('headless detection failed, assuming normal display');
126
+ }
127
+
128
+ headlessDetected = false;
129
+ return false;
130
+ }
131
+
132
+ /**
133
+ * strip background colors from output for headless mode
134
+ * keeps foreground colors, cursor movement, and everything else
135
+ * this fixes VTE rendering glitches where BG colors overlay text
136
+ */
137
+ function stripBackgroundColors(chunk) {
138
+ if (typeof chunk !== 'string') return chunk;
139
+
140
+ let result = chunk;
141
+ for (const pattern of ANSI_BG_PATTERNS) {
142
+ result = result.replace(pattern, '');
143
+ }
144
+ return result;
145
+ }
146
+
147
+ /**
148
+ * check if user is actively typing (within cooldown window)
76
149
  */
77
150
  function isTypingActive() {
78
151
  return (Date.now() - lastTypingTime) < config.typingCooldownMs;
@@ -80,8 +153,7 @@ function isTypingActive() {
80
153
 
81
154
  /**
82
155
  * detect if this looks like a stdin echo (single printable char or short sequence)
83
- * stdin echoes are typically: single chars, backspace seqs, arrow key echoes
84
- * we don't wanna mess with these or typing gets wonky
156
+ * stdin echoes are typically: single chars, backspace sequences, arrow key echoes
85
157
  */
86
158
  function isStdinEcho(chunk) {
87
159
  // single printable character (including space)
@@ -104,7 +176,7 @@ function isStdinEcho(chunk) {
104
176
  }
105
177
 
106
178
  /**
107
- * safe clear - defers if typing's active so we don't eat keystrokes
179
+ * safe clear - defers if typing active
108
180
  */
109
181
  function safeClearScrollback() {
110
182
  if (isTypingActive()) {
@@ -132,7 +204,7 @@ function safeClearScrollback() {
132
204
 
133
205
  /**
134
206
  * installs the fix - hooks into stdout and sigwinch
135
- * call this once at startup, calling again won't do anything
207
+ * call this once at startup, calling again is a no-op
136
208
  */
137
209
  function install() {
138
210
  if (installed || config.disabled) {
@@ -162,26 +234,15 @@ function install() {
162
234
 
163
235
  renderCount++;
164
236
 
165
- // track output for glitch detection
166
- if (glitchDetector) {
167
- glitchDetector.trackStdout();
168
- }
169
-
170
- // count lines so we can cap at 120
171
- const newlineCount = (chunk.match(/\n/g) || []).length;
172
- lineCount += newlineCount;
173
-
174
- // hit the limit? force a trim
175
- if (lineCount > config.maxLineCount) {
176
- log('line limit exceeded (' + lineCount + '/' + config.maxLineCount + '), forcing trim');
177
- lineCount = 0;
178
- chunk = CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE + chunk;
237
+ // HEADLESS MODE: strip background colors that cause VTE glitches
238
+ // keeps foreground colors, spinners, everything else working
239
+ if (isHeadless()) {
240
+ chunk = stripBackgroundColors(chunk);
179
241
  }
180
242
 
181
243
  // ink clears screen before re-render, we piggyback on that
182
244
  // but only if not actively typing
183
245
  if (chunk.includes(CLEAR_SCREEN) || chunk.includes(HOME_CURSOR)) {
184
- lineCount = 0; // Reset line count on screen clear
185
246
  if (config.clearAfterRenders > 0 && renderCount >= config.clearAfterRenders) {
186
247
  if (!isTypingActive()) {
187
248
  log('clearing scrollback after ' + renderCount + ' renders');
@@ -193,35 +254,11 @@ function install() {
193
254
  }
194
255
  }
195
256
 
196
- // /clear should actually clear everything (it's user-requested so do it now)
257
+ // /clear command should actually clear everything (immediate, user-requested)
197
258
  if (chunk.includes('Conversation cleared') || chunk.includes('Chat cleared')) {
198
259
  log('/clear detected, nuking scrollback');
199
- lineCount = 0;
200
260
  chunk = CLEAR_SCROLLBACK + chunk;
201
261
  }
202
-
203
- // glitched? try to recover
204
- if (glitchDetector && config.glitchRecoveryEnabled && !glitchRecoveryInProgress) {
205
- if (glitchDetector.isInGlitchState()) {
206
- glitchRecoveryInProgress = true;
207
- log('GLITCH DETECTED - initiating recovery');
208
-
209
- // Force clear scrollback immediately
210
- chunk = CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE + chunk;
211
- lineCount = 0;
212
- renderCount = 0;
213
-
214
- // Attempt full recovery asynchronously
215
- setImmediate(async () => {
216
- try {
217
- await glitchDetector.attemptRecovery();
218
- } catch (e) {
219
- log('recovery error:', e.message);
220
- }
221
- glitchRecoveryInProgress = false;
222
- });
223
- }
224
- }
225
262
  }
226
263
 
227
264
  return originalWrite(chunk, encoding, callback);
@@ -239,28 +276,9 @@ function install() {
239
276
  }, config.periodicClearMs);
240
277
  }
241
278
 
242
- // hook up the glitch detector
243
- if (glitchDetector) {
244
- glitchDetector.install();
245
-
246
- // Listen for glitch events
247
- glitchDetector.on('glitch-detected', (data) => {
248
- log('GLITCH EVENT:', JSON.stringify(data.signals));
249
- });
250
-
251
- glitchDetector.on('recovery-success', (data) => {
252
- log('recovery successful via', data.method);
253
- });
254
-
255
- glitchDetector.on('recovery-failed', () => {
256
- log('recovery failed - may need manual intervention');
257
- });
258
-
259
- log('glitch detector installed');
260
- }
261
-
262
279
  installed = true;
263
- log('installed successfully - v2.0.0 with glitch detection & 120-line limit');
280
+ const mode = isHeadless() ? 'HEADLESS MODE (stripping BG colors)' : 'normal mode';
281
+ log('installed successfully - v2.2.0 with headless fix - ' + mode);
264
282
  }
265
283
 
266
284
  function installResizeDebounce() {
@@ -300,7 +318,7 @@ function installResizeDebounce() {
300
318
  }
301
319
 
302
320
  /**
303
- * manually clear scrollback - call this whenever you want, it won't break anything
321
+ * manually clear scrollback - call this whenever you want
304
322
  */
305
323
  function clearScrollback() {
306
324
  if (originalWrite) {
@@ -315,40 +333,12 @@ function clearScrollback() {
315
333
  * get current stats for debugging
316
334
  */
317
335
  function getStats() {
318
- const stats = {
336
+ return {
319
337
  renderCount,
320
- lineCount,
321
338
  lastResizeTime,
322
339
  installed,
323
340
  config
324
341
  };
325
-
326
- // add glitch stats if available
327
- if (glitchDetector) {
328
- stats.glitch = glitchDetector.getMetrics();
329
- }
330
-
331
- return stats;
332
- }
333
-
334
- /**
335
- * force recovery if shit hits the fan
336
- */
337
- async function forceRecovery() {
338
- if (glitchDetector) {
339
- log('forcing recovery manually');
340
- return await glitchDetector.attemptRecovery();
341
- }
342
- // Fallback if no detector
343
- clearScrollback();
344
- return true;
345
- }
346
-
347
- /**
348
- * check if terminal's currently cooked
349
- */
350
- function isGlitched() {
351
- return glitchDetector ? glitchDetector.isInGlitchState() : false;
352
342
  }
353
343
 
354
344
  /**
@@ -362,7 +352,7 @@ function setConfig(key, value) {
362
352
  }
363
353
 
364
354
  /**
365
- * disable the fix (it's mostly for testing)
355
+ * disable the fix (mostly for testing)
366
356
  */
367
357
  function disable() {
368
358
  if (originalWrite) {
@@ -377,9 +367,7 @@ module.exports = {
377
367
  getStats,
378
368
  setConfig,
379
369
  disable,
380
- config,
381
- // NEW v2.0 exports
382
- forceRecovery,
383
- isGlitched,
384
- getDetector: () => glitchDetector
370
+ isHeadless,
371
+ stripBackgroundColors,
372
+ config
385
373
  };
package/loader.cjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * use like: node --require claudescreenfix-hardwicksoftware/loader.cjs $(which claude)
7
7
  *
8
8
  * this auto-installs the fix before claude code even starts
9
- * you don't need to change any code in claude itself - it just works
9
+ * no code changes needed in claude itself
10
10
  */
11
11
 
12
12
  const fix = require('./index.cjs');
package/package.json CHANGED
@@ -1,15 +1,12 @@
1
1
  {
2
2
  "name": "claudescreenfix-hardwicksoftware",
3
- "version": "2.1.0",
4
- "description": "fixes the scroll glitch in claude code cli - now with GLITCH DETECTION, 120-line limit enforcement, and auto-recovery",
3
+ "version": "2.2.1",
4
+ "description": "fixes scroll glitch + VNC/headless rendering in claude code cli - strips BG colors that break VTE on Xvfb",
5
5
  "main": "index.cjs",
6
6
  "bin": {
7
7
  "claude-fixed": "./bin/claude-fixed.js"
8
8
  },
9
- "scripts": {
10
- "test": "node bin/claude-fixed.js --version",
11
- "test-lib": "node -e \"const fix = require('./index.cjs'); fix.install(); console.log(fix.getStats());\""
12
- },
9
+ "scripts": {},
13
10
  "keywords": [
14
11
  "claude",
15
12
  "terminal",
@@ -18,10 +15,7 @@
18
15
  "ink",
19
16
  "cli",
20
17
  "glitch",
21
- "performance",
22
- "glitch-detection",
23
- "recovery",
24
- "120-line-limit"
18
+ "performance"
25
19
  ],
26
20
  "author": "jonhardwick-spec",
27
21
  "license": "MIT",
@@ -38,12 +32,8 @@
38
32
  "files": [
39
33
  "index.cjs",
40
34
  "loader.cjs",
41
- "glitch-detector.cjs",
42
35
  "bin/",
43
36
  "README.md",
44
37
  "LICENSE"
45
- ],
46
- "dependencies": {
47
- "node-pty": "^1.1.0"
48
- }
38
+ ]
49
39
  }
@@ -1,351 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * glitch detector - catches when the terminal's cooked
5
- *
6
- * watches for these signals:
7
- * - stdin goes quiet while stdout's still busy (input blocked)
8
- * - too many resize events too fast (sigwinch spam)
9
- * - render rate going crazy (ink thrashing)
10
- *
11
- * when 2+ signals fire we know shit's broken and try to recover
12
- */
13
-
14
- const EventEmitter = require('events');
15
- const { execSync, spawn } = require('child_process');
16
- const path = require('path');
17
- const os = require('os');
18
-
19
- // Default configuration
20
- const DEFAULT_CONFIG = {
21
- stdinTimeoutMs: 2000, // stdin silence threshold
22
- sigwinchThresholdMs: 10, // minimum ms between resize events
23
- sigwinchStormCount: 5, // resize events/sec to trigger storm alert
24
- renderRateLimit: 500, // max renders per minute before alert
25
- lineLimitMax: 120, // max terminal lines before trim
26
- checkIntervalMs: 500, // how often to check for glitch state
27
- recoveryDelayMs: 1000, // delay before recovery actions
28
- debug: process.env.CLAUDE_GLITCH_DETECTOR_DEBUG === '1'
29
- };
30
-
31
- class GlitchDetector extends EventEmitter {
32
- constructor(config = {}) {
33
- super();
34
- this.config = { ...DEFAULT_CONFIG, ...config };
35
-
36
- // State tracking
37
- this.lastStdinTime = Date.now();
38
- this.lastStdoutTime = 0;
39
- this.lastSigwinchTime = 0;
40
- this.sigwinchCount = 0;
41
- this.renderTimes = [];
42
- this.isGlitched = false;
43
- this.glitchStartTime = null;
44
-
45
- // Metrics
46
- this.metrics = {
47
- glitchesDetected: 0,
48
- recoveriesAttempted: 0,
49
- stdinSilenceEvents: 0,
50
- sigwinchStorms: 0,
51
- renderSpikes: 0
52
- };
53
-
54
- // Check interval handle
55
- this.checkInterval = null;
56
- this.installed = false;
57
- }
58
-
59
- log(...args) {
60
- if (this.config.debug) {
61
- process.stderr.write('[glitch-detector] ' + args.join(' ') + '\n');
62
- }
63
- }
64
-
65
- /**
66
- * Install the glitch detector - it hooks into process events
67
- */
68
- install() {
69
- if (this.installed) return;
70
-
71
- // Track stdin activity
72
- if (process.stdin.isTTY) {
73
- process.stdin.on('data', () => {
74
- this.lastStdinTime = Date.now();
75
- });
76
- }
77
-
78
- // Track SIGWINCH events
79
- const originalOn = process.on.bind(process);
80
- process.on = (event, handler) => {
81
- if (event === 'SIGWINCH') {
82
- return originalOn(event, (...args) => {
83
- this.onSigwinch();
84
- handler(...args);
85
- });
86
- }
87
- return originalOn(event, handler);
88
- };
89
-
90
- // Start periodic glitch check
91
- this.checkInterval = setInterval(() => {
92
- this.checkGlitchState();
93
- }, this.config.checkIntervalMs);
94
-
95
- this.installed = true;
96
- this.log('installed successfully');
97
- }
98
-
99
- /**
100
- * Track stdout write for activity monitoring
101
- * Call this from the stdout.write hook in index.cjs, it won't slow anything down
102
- */
103
- trackStdout() {
104
- this.lastStdoutTime = Date.now();
105
-
106
- // Track render times for rate limiting
107
- const now = Date.now();
108
- this.renderTimes.push(now);
109
-
110
- // Keep only last minute of renders
111
- this.renderTimes = this.renderTimes.filter(t => now - t < 60000);
112
- }
113
-
114
- /**
115
- * Handle SIGWINCH (resize) events
116
- */
117
- onSigwinch() {
118
- const now = Date.now();
119
- const interval = now - this.lastSigwinchTime;
120
- this.lastSigwinchTime = now;
121
-
122
- // Detect resize storm
123
- if (interval < this.config.sigwinchThresholdMs) {
124
- this.sigwinchCount++;
125
- this.log('rapid SIGWINCH detected, interval:', interval, 'ms');
126
- }
127
-
128
- // Decay counter over time
129
- setTimeout(() => {
130
- if (this.sigwinchCount > 0) this.sigwinchCount--;
131
- }, 1000);
132
- }
133
-
134
- /**
135
- * SIGNAL 1: Check for stdin silence during output activity
136
- * This is the main glitch signal - if stdin's dead but stdout's busy, we're cooked
137
- */
138
- checkStdinSilence() {
139
- const now = Date.now();
140
- const stdinSilence = now - this.lastStdinTime;
141
- const outputActive = (now - this.lastStdoutTime) < 5000; // output in last 5s
142
-
143
- // stdin's been quiet for 2+ sec while output's still going = we're glitched
144
- if (stdinSilence > this.config.stdinTimeoutMs && outputActive) {
145
- this.log('stdin silence detected:', stdinSilence, 'ms');
146
- this.metrics.stdinSilenceEvents++;
147
- return true;
148
- }
149
- return false;
150
- }
151
-
152
- /**
153
- * SIGNAL 2: Check for SIGWINCH storm
154
- */
155
- checkSigwinchStorm() {
156
- const isStorm = this.sigwinchCount >= this.config.sigwinchStormCount;
157
- if (isStorm) {
158
- this.log('SIGWINCH storm detected, count:', this.sigwinchCount);
159
- this.metrics.sigwinchStorms++;
160
- }
161
- return isStorm;
162
- }
163
-
164
- /**
165
- * SIGNAL 3: Check for render rate spike
166
- */
167
- checkRenderSpike() {
168
- const rendersPerMinute = this.renderTimes.length;
169
- const isSpike = rendersPerMinute > this.config.renderRateLimit;
170
- if (isSpike) {
171
- this.log('render spike detected:', rendersPerMinute, '/min');
172
- this.metrics.renderSpikes++;
173
- }
174
- return isSpike;
175
- }
176
-
177
- /**
178
- * Main glitch detection - it combines all signals
179
- * Uses 2-of-3 voting so we don't get false positives
180
- */
181
- checkGlitchState() {
182
- const stdinBlocked = this.checkStdinSilence();
183
- const sigwinchStorm = this.checkSigwinchStorm();
184
- const renderSpike = this.checkRenderSpike();
185
-
186
- const signals = [stdinBlocked, sigwinchStorm, renderSpike];
187
- const activeSignals = signals.filter(Boolean).length;
188
-
189
- // 2 of 3 signals = we're definitely glitched
190
- // OR stdin blocked alone (that's the most reliable one)
191
- const glitched = activeSignals >= 2 || stdinBlocked;
192
-
193
- if (glitched && !this.isGlitched) {
194
- this.isGlitched = true;
195
- this.glitchStartTime = Date.now();
196
- this.metrics.glitchesDetected++;
197
-
198
- this.log('GLITCH DETECTED!', {
199
- stdinBlocked,
200
- sigwinchStorm,
201
- renderSpike
202
- });
203
-
204
- this.emit('glitch-detected', {
205
- timestamp: Date.now(),
206
- signals: { stdinBlocked, sigwinchStorm, renderSpike },
207
- metrics: { ...this.metrics }
208
- });
209
- } else if (!glitched && this.isGlitched) {
210
- const duration = Date.now() - this.glitchStartTime;
211
- this.log('glitch resolved after', duration, 'ms');
212
- this.isGlitched = false;
213
- this.glitchStartTime = null;
214
-
215
- this.emit('glitch-resolved', { duration });
216
- }
217
-
218
- return glitched;
219
- }
220
-
221
- /**
222
- * Check if we're currently in glitched state
223
- */
224
- isInGlitchState() {
225
- return this.isGlitched;
226
- }
227
-
228
- /**
229
- * Get glitch duration in ms (0 if we aren't glitched)
230
- */
231
- getGlitchDuration() {
232
- if (!this.isGlitched || !this.glitchStartTime) return 0;
233
- return Date.now() - this.glitchStartTime;
234
- }
235
-
236
- /**
237
- * Force recovery attempt - it'll try screen commands and scrollback clears
238
- */
239
- async attemptRecovery() {
240
- if (!this.isGlitched) return false;
241
-
242
- this.log('attempting recovery...');
243
- this.metrics.recoveriesAttempted++;
244
-
245
- this.emit('recovery-started');
246
-
247
- try {
248
- // Method 1: Send Enter via screen (if we've got a session)
249
- const screenSession = process.env.STY || process.env.SPECMEM_SCREEN_SESSION;
250
- if (screenSession) {
251
- this.log('sending Enter via screen session:', screenSession);
252
- execSync(`screen -S "${screenSession}" -X stuff $'\\r'`, {
253
- stdio: 'ignore',
254
- timeout: 5000
255
- });
256
-
257
- await this.sleep(this.config.recoveryDelayMs);
258
-
259
- // Check if recovered
260
- if (!this.checkGlitchState()) {
261
- this.log('recovery successful via screen');
262
- this.emit('recovery-success', { method: 'screen' });
263
- return true;
264
- }
265
- }
266
-
267
- // Method 2: Force scrollback clear
268
- this.log('forcing scrollback clear');
269
- if (process.stdout.isTTY) {
270
- process.stdout.write('\x1b[3J'); // CLEAR_SCROLLBACK
271
- }
272
-
273
- await this.sleep(this.config.recoveryDelayMs);
274
-
275
- if (!this.checkGlitchState()) {
276
- this.log('recovery successful via scrollback clear');
277
- this.emit('recovery-success', { method: 'scrollback-clear' });
278
- return true;
279
- }
280
-
281
- this.log('recovery failed');
282
- this.emit('recovery-failed');
283
- return false;
284
-
285
- } catch (err) {
286
- this.log('recovery error:', err.message);
287
- this.emit('recovery-error', { error: err });
288
- return false;
289
- }
290
- }
291
-
292
- sleep(ms) {
293
- return new Promise(resolve => setTimeout(resolve, ms));
294
- }
295
-
296
- /**
297
- * Reset all state (call after recovery)
298
- */
299
- reset() {
300
- this.lastStdinTime = Date.now();
301
- this.lastStdoutTime = Date.now();
302
- this.sigwinchCount = 0;
303
- this.renderTimes = [];
304
- this.isGlitched = false;
305
- this.glitchStartTime = null;
306
- this.log('state reset');
307
- }
308
-
309
- /**
310
- * Get current metrics
311
- */
312
- getMetrics() {
313
- return {
314
- ...this.metrics,
315
- isGlitched: this.isGlitched,
316
- glitchDuration: this.getGlitchDuration(),
317
- renderRate: this.renderTimes.length,
318
- sigwinchRate: this.sigwinchCount,
319
- lastStdinAgo: Date.now() - this.lastStdinTime,
320
- lastStdoutAgo: Date.now() - this.lastStdoutTime
321
- };
322
- }
323
-
324
- /**
325
- * Disable the detector
326
- */
327
- disable() {
328
- if (this.checkInterval) {
329
- clearInterval(this.checkInterval);
330
- this.checkInterval = null;
331
- }
332
- this.installed = false;
333
- this.log('disabled');
334
- }
335
- }
336
-
337
- // Singleton instance
338
- let instance = null;
339
-
340
- function getDetector(config) {
341
- if (!instance) {
342
- instance = new GlitchDetector(config);
343
- }
344
- return instance;
345
- }
346
-
347
- module.exports = {
348
- GlitchDetector,
349
- getDetector,
350
- DEFAULT_CONFIG
351
- };