claudefix 2.7.0 → 2.7.2

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.
Files changed (3) hide show
  1. package/bin/claude-fixed.js +114 -153
  2. package/index.cjs +136 -281
  3. package/package.json +2 -2
@@ -295,131 +295,56 @@ if (debug) {
295
295
  */
296
296
  function stripColors(data) {
297
297
  let str = data;
298
- const isGtk4 = forceNuclear || terminalType === 'ptyxis' || terminalType === 'gtk4-vte';
299
298
 
300
- if (isGtk4) {
301
- // THERMONUCLEAR MODE for Ptyxis/GTK4 terminals
302
- // Strip ALL styling except basic foreground colors
303
- // This is aggressive but ensures clean rendering
304
-
305
- // First, strip ALL escape sequences
306
- str = str.replace(/\x1b\[[0-9;]*m/g, (match) => {
307
- // Only keep sequences that are JUST foreground colors (30-37, 90-97, 38;5;X, 38;2;R;G;B)
308
- // and reset (0)
309
-
310
- // Extract the codes
311
- const codes = match.slice(2, -1).split(';').filter(c => c !== '');
312
-
313
- if (codes.length === 0) return '\x1b[0m';
314
-
315
- // Filter to only allowed codes
316
- const allowed = [];
317
- let i = 0;
318
- while (i < codes.length) {
319
- const code = parseInt(codes[i], 10);
320
-
321
- // Reset
322
- if (code === 0) {
323
- allowed.push('0');
324
- i++;
325
- }
326
- // Bold (1), but NOT dim (2)
327
- else if (code === 1) {
328
- allowed.push('1');
329
- i++;
330
- }
331
- // Standard foreground (30-37)
332
- else if (code >= 30 && code <= 37) {
333
- allowed.push(codes[i]);
334
- i++;
335
- }
336
- // Default foreground (39)
337
- else if (code === 39) {
338
- allowed.push('39');
339
- i++;
340
- }
341
- // Bright foreground (90-97)
342
- else if (code >= 90 && code <= 97) {
343
- allowed.push(codes[i]);
344
- i++;
345
- }
346
- // 256-color foreground (38;5;X)
347
- else if (code === 38 && codes[i + 1] === '5' && codes[i + 2]) {
348
- allowed.push('38', '5', codes[i + 2]);
349
- i += 3;
350
- }
351
- // True color foreground (38;2;R;G;B)
352
- else if (code === 38 && codes[i + 1] === '2' && codes[i + 4]) {
353
- allowed.push('38', '2', codes[i + 2], codes[i + 3], codes[i + 4]);
354
- i += 5;
355
- }
356
- // Skip backgrounds (40-47, 49, 100-107, 48;5;X, 48;2;R;G;B)
357
- else if (code >= 40 && code <= 49) {
358
- i++;
359
- }
360
- else if (code >= 100 && code <= 107) {
361
- i++;
362
- }
363
- else if (code === 48 && codes[i + 1] === '5') {
364
- i += 3; // Skip 48;5;X
365
- }
366
- else if (code === 48 && codes[i + 1] === '2') {
367
- i += 5; // Skip 48;2;R;G;B
368
- }
369
- // Skip other problematic codes (2=dim, 7=inverse, 8=hidden)
370
- else if (code === 2 || code === 7 || code === 8 || code === 22 || code === 27 || code === 28) {
371
- i++;
372
- }
373
- else {
374
- // Unknown - skip it
375
- i++;
376
- }
299
+ // Universal approach: parse each SGR sequence, keep only safe codes
300
+ // This handles ALL compound sequences correctly (e.g. \x1b[1;38;5;196;48;5;236m)
301
+ // by parsing code-by-code instead of regex pattern matching
302
+ str = str.replace(/\x1b\[[0-9;]*m/g, (match) => {
303
+ const codes = match.slice(2, -1).split(';').filter(c => c !== '');
304
+
305
+ if (codes.length === 0) return '\x1b[0m';
306
+
307
+ const allowed = [];
308
+ let i = 0;
309
+ while (i < codes.length) {
310
+ const code = parseInt(codes[i], 10);
311
+
312
+ // Reset
313
+ if (code === 0) { allowed.push('0'); i++; }
314
+ // Bold (1), italic (3), underline (4), strikethrough (9)
315
+ else if (code === 1 || code === 3 || code === 4 || code === 9) { allowed.push(codes[i]); i++; }
316
+ // Bold off (22), italic off (23), underline off (24), strikethrough off (29)
317
+ else if (code === 22 || code === 23 || code === 24 || code === 29) { allowed.push(codes[i]); i++; }
318
+ // Standard foreground (30-37)
319
+ else if (code >= 30 && code <= 37) { allowed.push(codes[i]); i++; }
320
+ // Default foreground (39)
321
+ else if (code === 39) { allowed.push('39'); i++; }
322
+ // Bright foreground (90-97)
323
+ else if (code >= 90 && code <= 97) { allowed.push(codes[i]); i++; }
324
+ // 256-color foreground (38;5;X)
325
+ else if (code === 38 && codes[i + 1] === '5' && codes[i + 2]) {
326
+ allowed.push('38', '5', codes[i + 2]); i += 3;
377
327
  }
328
+ // True color foreground (38;2;R;G;B)
329
+ else if (code === 38 && codes[i + 1] === '2' && codes[i + 4]) {
330
+ allowed.push('38', '2', codes[i + 2], codes[i + 3], codes[i + 4]); i += 5;
331
+ }
332
+ // Skip ALL backgrounds: 40-47, 49, 100-107
333
+ else if ((code >= 40 && code <= 49) || (code >= 100 && code <= 107)) { i++; }
334
+ // Skip 256-color bg (48;5;X)
335
+ else if (code === 48 && codes[i + 1] === '5') { i += 3; }
336
+ // Skip true color bg (48;2;R;G;B)
337
+ else if (code === 48 && codes[i + 1] === '2') { i += 5; }
338
+ // Skip dim (2), inverse (7), hidden (8) and their offs
339
+ else if (code === 2 || code === 7 || code === 8 || code === 27 || code === 28) { i++; }
340
+ // Unknown - skip
341
+ else { i++; }
342
+ }
378
343
 
379
- if (allowed.length === 0) return '';
380
- return `\x1b[${allowed.join(';')}m`;
381
- });
382
-
383
- return str;
384
- }
385
-
386
- // Regular mode for other terminals
387
- // Remove standalone background color sequences entirely
388
- str = str.replace(/\x1b\[48;5;\d+m/g, ''); // 256-color bg
389
- str = str.replace(/\x1b\[48;2;\d+;\d+;\d+m/g, ''); // true color bg
390
- str = str.replace(/\x1b\[4[0-7]m/g, ''); // standard bg 40-47
391
- str = str.replace(/\x1b\[49m/g, ''); // default bg
392
- str = str.replace(/\x1b\[10[0-7]m/g, ''); // bright bg 100-107
393
- str = str.replace(/\x1b\[7m/g, ''); // inverse
394
- str = str.replace(/\x1b\[27m/g, ''); // inverse off
395
-
396
- // NUCLEAR MODE: Extra aggressive stripping for VTE issues
397
- str = str.replace(/\x1b\[2m/g, ''); // dim text (causes grey)
398
- str = str.replace(/\x1b\[22m/g, ''); // dim off
399
- str = str.replace(/\x1b\[8m/g, ''); // hidden text
400
- str = str.replace(/\x1b\[28m/g, ''); // hidden off
401
-
402
- // Strip any remaining compound sequences with background codes
403
- // Matches patterns like \x1b[0;48;5;236m or \x1b[38;5;196;48;5;236m
404
- str = str.replace(/\x1b\[([0-9;]*?)(;?48;[52];[0-9;]+)(;?[0-9;]*)m/g, (match, before, bg, after) => {
405
- const parts = [before, after].filter(p => p && p.length > 0 && p !== ';');
406
- if (parts.length === 0) return '\x1b[0m';
407
- return `\x1b[${parts.join(';').replace(/^;|;$/g, '').replace(/;;+/g, ';')}m`;
344
+ if (allowed.length === 0) return '';
345
+ return `\x1b[${allowed.join(';')}m`;
408
346
  });
409
347
 
410
- // For combined sequences like \x1b[1;41m (bold + red bg), remove just the bg part
411
- // This regex finds sequences with bg codes and removes just those codes
412
- str = str.replace(/\x1b\[([0-9;]*)(?:;?)(4[0-7]|49|10[0-7]|48;5;\d+|48;2;\d+;\d+;\d+)(?:;?)([0-9;]*)m/g,
413
- (match, before, bg, after) => {
414
- const parts = [before, after].filter(p => p && p.length > 0);
415
- if (parts.length === 0) return '';
416
- return `\x1b[${parts.join(';')}m`;
417
- });
418
-
419
- // Clean up any malformed sequences that might be left
420
- str = str.replace(/\x1b\[;+m/g, '\x1b[0m'); // \x1b[;;m -> \x1b[0m
421
- str = str.replace(/\x1b\[m/g, '\x1b[0m'); // \x1b[m -> \x1b[0m
422
-
423
348
  return str;
424
349
  }
425
350
 
@@ -480,6 +405,15 @@ function getTerminalType() {
480
405
  return 'ptyxis';
481
406
  }
482
407
 
408
+ // XFCE4 Terminal - uses VTE but handles ANSI fine, does NOT need thermonuclear mode
409
+ // Must check BEFORE the VTE version check or it gets misclassified as gtk4-vte
410
+ if (termProgram === 'xfce4-terminal' || termProgram === 'Xfce Terminal' ||
411
+ process.env.XFCE_TERMINAL_VERSION ||
412
+ process.env.WINDOWPATH || // XFCE sets this
413
+ (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase().includes('xfce')) {
414
+ return 'xfce-terminal';
415
+ }
416
+
483
417
  // GTK4 terminals (like Ptyxis) often have GDK_BACKEND set
484
418
  if (gdkBackend === 'wayland' && vteVersion) {
485
419
  return 'gtk4-vte';
@@ -871,15 +805,11 @@ if (!usePTY) {
871
805
  const cols = process.stdout.columns || 80;
872
806
 
873
807
  if (sshMode || !showFooter) {
874
- // SSH mode or no footer: NO scroll region manipulation
875
808
  ptyProcess.resize(cols, rows);
876
809
  } else {
877
- // Local mode with footer: reserve bottom row(s) for footer
878
- // FIX: Handle edge case where terminal is too small
810
+ // Reserve bottom row(s) for footer via scroll region + PTY resize
879
811
  const contentRows = Math.max(1, rows - footerRows);
880
812
 
881
- // FIX: Reset scroll region first, then set new one to avoid clipping
882
- // This prevents content from being cut off during dynamic resize
883
813
  process.stdout.write(
884
814
  '\x1b[r' + // Reset scroll region to full terminal
885
815
  '\x1b7' + // Save cursor position
@@ -887,7 +817,6 @@ if (!usePTY) {
887
817
  '\x1b8' // Restore cursor position
888
818
  );
889
819
 
890
- // Resize PTY to content area (excludes footer row(s))
891
820
  ptyProcess.resize(cols, contentRows);
892
821
  }
893
822
  }
@@ -932,6 +861,13 @@ if (!usePTY) {
932
861
  );
933
862
  }
934
863
 
864
+ // FIX (Linux only): Clear screen once at startup to prevent ghost frames.
865
+ // Ink renders inline (no alternate screen) and re-renders during startup cause
866
+ // content to stack/triplicate. A single clear before first output fixes this.
867
+ // NOTE: Do NOT use alternate screen (\x1b[?1049h) — it makes Ink switch to
868
+ // full-screen mode where it sends \x1b[H\x1b[J which erases the footer.
869
+ let startupCleared = false;
870
+
935
871
  // Initial setup
936
872
  setupScrollRegion();
937
873
  if (!sshMode && showFooter) drawFooter();
@@ -969,53 +905,74 @@ if (!usePTY) {
969
905
  // macOS Terminal.app doesn't have VTE bugs, so skip aggressive escape mangling
970
906
  const isAppleTerminal = process.env.TERM_PROGRAM === 'Apple_Terminal';
971
907
 
972
- ptyProcess.onData((data) => {
973
- // Stop forwarding output once we're cleaning up - prevents Claude's
974
- // dying output from overwriting our exit banner
975
- if (exiting) return;
908
+ // FIX: Buffer-and-flush approach for ghost frame elimination on Linux.
909
+ // Problem: Ink sends renders in multiple small chunks. Injecting \x1b[J on any
910
+ // individual chunk either misses (threshold not met) or nukes partial renders.
911
+ // Solution: Buffer ALL output, flush after a short idle gap (16ms). When flushing,
912
+ // if the buffer contains \x1b[H (home cursor = new render), inject \x1b[J after it.
913
+ // Since the entire render is flushed atomically, clear + content arrive together.
914
+ let outputBuffer = '';
915
+ let flushTimer = null;
916
+ const FLUSH_DELAY_MS = 16; // Normal flush delay (~1 frame)
917
+ const COALESCE_DELAY_MS = 80; // Extended delay during rapid full repaints
918
+ let lastFullRenderTime = 0; // When we last flushed a full repaint
919
+
920
+ function processAndFlush() {
921
+ flushTimer = null;
922
+ if (!outputBuffer || exiting) return;
923
+
924
+ let output = outputBuffer;
925
+ outputBuffer = '';
926
+
976
927
  // Only strip colors if config enabled and env var not disabled
977
- // On macOS: skip color stripping entirely for Apple Terminal (no VTE bugs)
978
928
  const shouldStrip = config.colorStripping &&
979
929
  process.env.CLAUDE_STRIP_BG_COLORS !== '0' &&
980
930
  !isAppleTerminal;
981
- let output = shouldStrip ? stripColors(data) : data;
931
+ if (shouldStrip) output = stripColors(output);
982
932
 
983
- // FIX: Intercept scroll region resets from Ink/Claude output.
984
- // Ink sends \x1b[r which resets scroll region to full terminal,
985
- // causing our footer area to become content area and old content bleeds through.
986
- // Replace with our constrained scroll region.
933
+ // Intercept scroll region resets from Ink — replace with our constrained region
987
934
  if (showFooter && !sshMode) {
988
935
  const cr = contentRows();
989
- // Replace bare scroll region reset with our constrained one
990
936
  output = output.replace(/\x1b\[r/g, `\x1b[1;${cr}r`);
991
- // Also catch explicit full-terminal scroll regions like \x1b[1;24r
992
937
  const fullRows = process.stdout.rows || 24;
993
938
  output = output.replace(new RegExp(`\\x1b\\[1;${fullRows}r`, 'g'), `\x1b[1;${cr}r`);
994
939
  }
995
940
 
996
- // FIX (Linux only): When Ink sends a screen clear (\x1b[2J), inject scrollback
997
- // clear to prevent old content from sticking around in VTE terminals.
998
- // SKIP on macOS: Terminal.app handles \x1b[3J differently and it causes
999
- // visual glitches (screen flashing, content disappearing).
941
+ // FIX (Linux only): Inject scrollback clear on full screen clears
1000
942
  if (!isMac) {
1001
- if (output.includes('\x1b[2J') || output.includes('\x1b[3J')) {
943
+ if (output.includes('\x1b[2J')) {
1002
944
  output = output.replace(/\x1b\[2J/g, '\x1b[2J\x1b[3J');
1003
945
  }
1004
946
  }
1005
947
 
1006
- // FIX (Linux only): Detect Ink's home cursor (\x1b[H or \x1b[1;1H) followed
1007
- // by content - this is a differential re-render. Clear to end of screen after
1008
- // home to prevent stale content below the new render from showing through.
1009
- // SKIP on macOS: Apple Terminal repaints on \x1b[J which causes rapid
1010
- // flickering when combined with Ink's frequent re-renders.
1011
- if (!isMac) {
1012
- if (output.includes('\x1b[H') || output.includes('\x1b[1;1H')) {
1013
- output = output.replace(/(\x1b\[(?:1;1)?H)/, '$1\x1b[J');
1014
- }
948
+ // FIX (Linux only): Clear stale content when Ink does a FULL re-render.
949
+ // Ink sends \x1b[H for both full repaints AND partial updates (just prompt).
950
+ // We must ONLY clear on full repaints or we wipe content Ink didn't re-send.
951
+ // Since we buffer the entire render cycle, we can check the buffer size:
952
+ // - Full repaint: large buffer (most of the screen rewritten)
953
+ // - Partial update (prompt only): small buffer
954
+ // Threshold: at least half the screen worth of content (contentRows * 30 bytes)
955
+ // FIX (Linux only): Clear screen once before first output to prevent
956
+ // startup ghost frames (triplicated content from Ink's initial renders)
957
+ if (!isMac && !startupCleared) {
958
+ startupCleared = true;
959
+ output = '\x1b[2J\x1b[3J\x1b[H' + output;
1015
960
  }
1016
961
 
1017
962
  process.stdout.write(output);
1018
963
  if (showFooter) scheduleFooterDraw();
964
+ }
965
+
966
+ ptyProcess.onData((data) => {
967
+ if (exiting) return;
968
+ // Accumulate into buffer
969
+ outputBuffer += data;
970
+ // Reset flush timer — wait for output burst to finish
971
+ if (flushTimer) clearTimeout(flushTimer);
972
+ // Use longer delay if we recently flushed a full render (coalesce rapid repaints)
973
+ const recentFullRender = (Date.now() - lastFullRenderTime) < 200;
974
+ const delay = recentFullRender ? COALESCE_DELAY_MS : FLUSH_DELAY_MS;
975
+ flushTimer = setTimeout(processAndFlush, delay);
1019
976
  });
1020
977
 
1021
978
  // Forward stdin with Ctrl+Shift+H hotkey
@@ -1080,7 +1037,9 @@ if (!usePTY) {
1080
1037
  clearInterval(memCheckInterval);
1081
1038
  if (gcInterval) clearInterval(gcInterval);
1082
1039
  if (pendingDraw) clearTimeout(pendingDraw);
1083
- // Clean exit: reset scroll region, clear screen, show exit banner
1040
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
1041
+ if (outputBuffer) { process.stdout.write(outputBuffer); outputBuffer = ''; }
1042
+ // Clean exit: leave alternate screen, reset scroll region
1084
1043
  if (!sshMode && showFooter) {
1085
1044
  process.stdout.write(
1086
1045
  '\x1b[r' + // Reset scroll region to full terminal
@@ -1138,6 +1097,8 @@ if (!usePTY) {
1138
1097
  if (cpuLimiter) try { cpuLimiter.kill(); } catch {}
1139
1098
  if (footerInterval) clearInterval(footerInterval);
1140
1099
  if (pendingDraw) clearTimeout(pendingDraw);
1100
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
1101
+ if (outputBuffer) { process.stdout.write(outputBuffer); outputBuffer = ''; }
1141
1102
  if (!sshMode && showFooter) {
1142
1103
  process.stdout.write(
1143
1104
  '\x1b[r' + // Reset scroll region
package/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * claudefix - stops the scroll glitch from cooking your terminal
4
+ * claudescreenfix-hardwicksoftware - stops the scroll glitch from cooking your terminal
5
5
  *
6
6
  * the problem:
7
7
  * claude code uses ink (react for terminals) and it dont clear scrollback
@@ -13,8 +13,6 @@
13
13
  * - hook stdout.write to inject scrollback clears periodically
14
14
  * - debounce SIGWINCH so resize aint thrashing
15
15
  * - enhance /clear to actually clear scrollback not just the screen
16
- * - RESOURCE LIMITING: cap CPU + RAM via cpulimit/cgroup/nice + V8 flags
17
- * - GC FORCING: periodic garbage collection to fight V8 heap bloat
18
16
  *
19
17
  * FIXED v1.0.1: typing issue where stdin echo was being intercepted
20
18
  * - now detects stdin echo writes and passes them through unmodified
@@ -25,15 +23,7 @@
25
23
  * - auto-detects Xvfb/VNC/headless environments
26
24
  * - strips BACKGROUND colors that cause VTE rendering glitches
27
25
  * - keeps foreground colors and spinners working perfectly
28
- *
29
- * NEW v2.7.0: resource limiting
30
- * - reads ~/.claudefix.json for memPercent/cpuPercent settings
31
- * - applies --max-old-space-size to child processes via NODE_OPTIONS
32
- * - cpulimit/cgroup/renice for CPU capping
33
- * - periodic forced GC via --expose-gc
34
- * - RAM monitoring with threshold warnings
35
- *
36
- * Developed by Hardwick Software Services - https://justcalljon.pro
26
+ * - your Zesting still zests, just no broken color blocks
37
27
  */
38
28
 
39
29
 
@@ -88,14 +78,21 @@ function stripCompoundBgCodes(str) {
88
78
 
89
79
  // supported terminals - only run fix on these
90
80
  const SUPPORTED_TERMINALS = [
91
- 'xterm', 'xterm-256color', 'screen', 'screen-256color',
92
- 'tmux', 'tmux-256color', 'rxvt', 'rxvt-unicode', 'rxvt-unicode-256color',
93
- 'vt100', 'vt220', 'linux', 'ansi', 'cygwin',
94
- 'alacritty', 'foot', 'foot-extra', 'kitty', 'kitty-direct', 'xterm-kitty',
95
- 'wezterm', 'xterm-ghostty', 'ghostty',
96
- 'dumb' // support dumb terminals too (VNC/headless)
81
+ 'xterm', 'xterm-256color', 'xterm-color',
82
+ 'screen', 'screen-256color',
83
+ 'tmux', 'tmux-256color',
84
+ 'linux', 'vt100', 'vt220',
85
+ 'rxvt', 'rxvt-unicode', 'rxvt-unicode-256color',
86
+ 'gnome', 'gnome-256color',
87
+ 'konsole', 'konsole-256color',
97
88
  ];
98
89
 
90
+ function isTerminalSupported() {
91
+ const term = process.env.TERM || '';
92
+ // check exact match or prefix match
93
+ return SUPPORTED_TERMINALS.some(t => term === t || term.startsWith(t + '-'));
94
+ }
95
+
99
96
  // config - tweak these if needed
100
97
  const config = {
101
98
  resizeDebounceMs: 150, // how long to wait before firing resize
@@ -108,159 +105,6 @@ const config = {
108
105
  stripColors: process.env.CLAUDE_STRIP_COLORS !== '0', // strip by default, disable with =0
109
106
  };
110
107
 
111
- // ============================================================================
112
- // RESOURCE LIMITER — Cap Claude's CPU & RAM usage
113
- // ============================================================================
114
- // Reads from ~/.claudefix.json (memPercent, cpuPercent) or env vars:
115
- // CLAUDE_MAX_CPU=50 → max 50% CPU
116
- // CLAUDE_MAX_RAM=4096 → max 4GB RSS in MB
117
- // CLAUDEFIX_MEM_PERCENT=35 → 35% of system RAM for V8 heap
118
- // CLAUDEFIX_CPU_PERCENT=80 → 80% CPU cap
119
- // CLAUDE_RESOURCE_LIMIT=0 → disable resource limiting
120
-
121
- const os = require('os');
122
- const fs = require('fs');
123
- const path = require('path');
124
-
125
- // Load user config from ~/.claudefix.json
126
- function _loadUserConfig() {
127
- try {
128
- const cfgPath = path.join(os.homedir(), '.claudefix.json');
129
- if (fs.existsSync(cfgPath)) {
130
- return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
131
- }
132
- } catch (_) {}
133
- return {};
134
- }
135
-
136
- const _userConfig = _loadUserConfig();
137
-
138
- const resourceConfig = {
139
- // Memory: percentage of system RAM for V8 heap
140
- memPercent: parseInt(process.env.CLAUDEFIX_MEM_PERCENT || '', 10) || _userConfig.memPercent || 35,
141
- memEnabled: _userConfig.memoryLimit !== false && process.env.CLAUDE_RESOURCE_LIMIT !== '0',
142
- // CPU: percentage cap (0 = no limit)
143
- cpuPercent: parseInt(process.env.CLAUDEFIX_CPU_PERCENT || process.env.CLAUDE_MAX_CPU || '', 10) || _userConfig.cpuPercent || 0,
144
- // RAM monitoring
145
- maxRamMB: parseInt(process.env.CLAUDE_MAX_RAM || '', 10) || 0, // 0 = auto from memPercent
146
- checkIntervalMs: 30000,
147
- gcIntervalMs: 60000,
148
- enabled: process.env.CLAUDE_RESOURCE_LIMIT !== '0',
149
- _intervalId: null,
150
- _gcIntervalId: null,
151
- _cpulimitPid: null,
152
- };
153
-
154
- // Calculate actual limits
155
- const TOTAL_MEM_MB = Math.floor(os.totalmem() / 1048576);
156
- const MAX_HEAP_MB = resourceConfig.maxRamMB || Math.floor(TOTAL_MEM_MB * Math.min(100, Math.max(1, resourceConfig.memPercent)) / 100);
157
- const WARN_THRESHOLD_MB = Math.floor(MAX_HEAP_MB * 0.7);
158
- const CRITICAL_THRESHOLD_MB = Math.floor(MAX_HEAP_MB * 0.9);
159
-
160
- function installResourceLimiter() {
161
- if (!resourceConfig.enabled || process.platform !== 'linux') return;
162
-
163
- const { execSync, spawn } = require('child_process');
164
- const pid = process.pid;
165
-
166
- // --- Set NODE_OPTIONS for V8 heap limit + GC exposure ---
167
- // This affects the CURRENT process and any child processes
168
- if (resourceConfig.memEnabled) {
169
- const existingOpts = process.env.NODE_OPTIONS || '';
170
- if (!existingOpts.includes('--max-old-space-size')) {
171
- process.env.NODE_OPTIONS = (existingOpts + ' --max-old-space-size=' + MAX_HEAP_MB).trim();
172
- log('resource: NODE_OPTIONS set --max-old-space-size=' + MAX_HEAP_MB + 'MB (' + resourceConfig.memPercent + '% of ' + TOTAL_MEM_MB + 'MB)');
173
- }
174
- if (!existingOpts.includes('--expose-gc')) {
175
- process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS + ' --expose-gc').trim();
176
- log('resource: NODE_OPTIONS set --expose-gc');
177
- }
178
- }
179
-
180
- // --- CPU limiting via cpulimit (if configured and available) ---
181
- if (resourceConfig.cpuPercent > 0) {
182
- const cpuCores = os.cpus().length;
183
- // cpulimit uses percentage per-core, so 50% on 4 cores = 200% cpulimit value
184
- const cpulimitVal = Math.floor(resourceConfig.cpuPercent * cpuCores / 100) * 100 || resourceConfig.cpuPercent;
185
-
186
- try {
187
- execSync('which cpulimit 2>/dev/null', { stdio: 'pipe' });
188
- const cpulimitProc = spawn('cpulimit', ['-p', String(pid), '-l', String(cpulimitVal), '-z'], {
189
- stdio: 'ignore', detached: true
190
- });
191
- cpulimitProc.unref();
192
- resourceConfig._cpulimitPid = cpulimitProc.pid;
193
- log('resource: cpulimit attached (pid=' + pid + ', limit=' + resourceConfig.cpuPercent + '%, cpulimit=' + cpulimitVal + ')');
194
- } catch (_) {
195
- // No cpulimit — try cgroup v2
196
- try {
197
- const cgroupDir = '/sys/fs/cgroup/claudefix-' + pid;
198
- if (fs.existsSync('/sys/fs/cgroup/cgroup.controllers')) {
199
- fs.mkdirSync(cgroupDir, { recursive: true });
200
- const quota = resourceConfig.cpuPercent * 1000;
201
- fs.writeFileSync(cgroupDir + '/cpu.max', quota + ' 100000');
202
- fs.writeFileSync(cgroupDir + '/cgroup.procs', String(pid));
203
- log('resource: cgroup v2 attached (cpu=' + resourceConfig.cpuPercent + '%)');
204
- }
205
- } catch (_cgErr) {
206
- // Fallback: renice
207
- try {
208
- const niceVal = Math.max(0, Math.min(19, Math.floor(19 * (1 - resourceConfig.cpuPercent / 100))));
209
- execSync('renice ' + niceVal + ' -p ' + pid + ' 2>/dev/null', { stdio: 'pipe' });
210
- log('resource: renice applied (nice=' + niceVal + ')');
211
- } catch (_) {}
212
- }
213
- }
214
- }
215
-
216
- // --- Periodic forced GC ---
217
- resourceConfig._gcIntervalId = setInterval(() => {
218
- try {
219
- if (global.gc) {
220
- global.gc();
221
- log('resource: forced GC');
222
- }
223
- } catch (_) {}
224
- }, resourceConfig.gcIntervalMs);
225
- if (resourceConfig._gcIntervalId && resourceConfig._gcIntervalId.unref) {
226
- resourceConfig._gcIntervalId.unref();
227
- }
228
-
229
- // --- RAM monitoring ---
230
- resourceConfig._intervalId = setInterval(() => {
231
- try {
232
- const mem = process.memoryUsage();
233
- const rssMB = Math.round(mem.rss / 1048576);
234
- const heapMB = Math.round(mem.heapUsed / 1048576);
235
-
236
- if (rssMB > CRITICAL_THRESHOLD_MB) {
237
- log('resource: CRITICAL RAM ' + rssMB + 'MB (heap=' + heapMB + 'MB) exceeds ' + CRITICAL_THRESHOLD_MB + 'MB');
238
- if (global.gc) global.gc();
239
- } else if (rssMB > WARN_THRESHOLD_MB) {
240
- log('resource: WARNING RAM ' + rssMB + 'MB approaching limit ' + MAX_HEAP_MB + 'MB');
241
- }
242
- } catch (_) {}
243
- }, resourceConfig.checkIntervalMs);
244
- if (resourceConfig._intervalId && resourceConfig._intervalId.unref) {
245
- resourceConfig._intervalId.unref();
246
- }
247
- }
248
-
249
- function cleanupResourceLimiter() {
250
- if (resourceConfig._intervalId) {
251
- clearInterval(resourceConfig._intervalId);
252
- resourceConfig._intervalId = null;
253
- }
254
- if (resourceConfig._gcIntervalId) {
255
- clearInterval(resourceConfig._gcIntervalId);
256
- resourceConfig._gcIntervalId = null;
257
- }
258
- if (resourceConfig._cpulimitPid) {
259
- try { process.kill(resourceConfig._cpulimitPid); } catch (_) {}
260
- resourceConfig._cpulimitPid = null;
261
- }
262
- }
263
-
264
108
  // state tracking
265
109
  let renderCount = 0;
266
110
  let lastResizeTime = 0;
@@ -273,7 +117,7 @@ let clearIntervalId = null;
273
117
 
274
118
  function log(...args) {
275
119
  if (config.debug) {
276
- process.stderr.write('[claudefix] ' + args.join(' ') + '\n');
120
+ process.stderr.write('[terminal-fix] ' + args.join(' ') + '\n');
277
121
  }
278
122
  }
279
123
 
@@ -284,79 +128,85 @@ function log(...args) {
284
128
  * fixes VTE rendering glitches where BG colors overlay text
285
129
  */
286
130
  function stripBackgroundColors(chunk) {
287
- let str = typeof chunk === 'string' ? chunk : chunk.toString();
131
+ if (typeof chunk !== 'string') return chunk;
132
+
133
+ let result = chunk;
288
134
 
289
- // first pass: strip simple standalone sequences
135
+ // first pass: strip simple bg patterns
290
136
  for (const pattern of ANSI_BG_PATTERNS) {
291
- str = str.replace(pattern, '');
137
+ result = result.replace(pattern, '');
292
138
  }
293
139
 
294
- // second pass: strip compound sequences
295
- str = stripCompoundBgCodes(str);
140
+ // second pass: handle compound sequences like \x1b[0;48;5;236m
141
+ result = stripCompoundBgCodes(result);
296
142
 
297
- return str;
143
+ // cleanup: remove empty/malformed sequences
144
+ result = result.replace(/\x1b\[;*m/g, '\x1b[0m'); // \x1b[;m -> \x1b[0m
145
+ result = result.replace(/\x1b\[m/g, '\x1b[0m'); // \x1b[m -> \x1b[0m
146
+
147
+ return result;
298
148
  }
299
149
 
300
150
  /**
301
- * check if user is currently typing (within cooldown period)
151
+ * check if user is actively typing (within cooldown window)
302
152
  */
303
153
  function isTypingActive() {
304
154
  return (Date.now() - lastTypingTime) < config.typingCooldownMs;
305
155
  }
306
156
 
307
157
  /**
308
- * detect if a write is an stdin echo (user typing)
309
- * these are typically single chars or short sequences
158
+ * detect if this looks like a stdin echo (single printable char or short sequence)
159
+ * stdin echoes are typically: single chars, backspace sequences, arrow key echoes
310
160
  */
311
161
  function isStdinEcho(chunk) {
312
- if (typeof chunk !== 'string') return false;
313
- const len = chunk.length;
314
-
315
- // single printable char = definitely typing
316
- if (len === 1 && chunk.charCodeAt(0) >= 32) return true;
317
-
318
- // newline/carriage return = enter key
319
- if (len === 1 && (chunk === '\n' || chunk === '\r')) return true;
320
-
321
- // backspace sequences
322
- if (chunk === '\b \b' || chunk === '\x7f') return true;
323
-
324
- // short escape sequences (arrow keys, etc)
325
- if (len <= 4 && chunk.startsWith('\x1b[')) return true;
326
-
327
- // tab completion results are usually short
328
- if (len <= 20 && !chunk.includes('\n') && !chunk.includes('\x1b[')) return true;
329
-
162
+ // single printable character (including space)
163
+ if (chunk.length === 1 && chunk.charCodeAt(0) >= 32 && chunk.charCodeAt(0) <= 126) {
164
+ return true;
165
+ }
166
+ // backspace/delete echo (usually 1-3 chars with control codes)
167
+ if (chunk.length <= 4 && (chunk.includes('\b') || chunk.includes('\x7f'))) {
168
+ return true;
169
+ }
170
+ // arrow key echo or cursor movement (short escape sequences)
171
+ if (chunk.length <= 6 && chunk.startsWith('\x1b[') && !chunk.includes('J') && !chunk.includes('H')) {
172
+ return true;
173
+ }
174
+ // enter/newline
175
+ if (chunk === '\n' || chunk === '\r' || chunk === '\r\n') {
176
+ return true;
177
+ }
330
178
  return false;
331
179
  }
332
180
 
333
181
  /**
334
- * safely clear scrollback without disrupting display
335
- * uses save/restore cursor and waits for non-typing moment
182
+ * safe clear - defers if typing active
336
183
  */
337
184
  function safeClearScrollback() {
338
- if (!originalWrite) return;
339
-
340
- // don't clear during typing
341
185
  if (isTypingActive()) {
342
- pendingClear = true;
186
+ if (!pendingClear) {
187
+ pendingClear = true;
188
+ log('deferring clear - typing active');
189
+ setTimeout(() => {
190
+ pendingClear = false;
191
+ if (!isTypingActive()) {
192
+ safeClearScrollback();
193
+ }
194
+ }, config.typingCooldownMs);
195
+ }
343
196
  return;
344
197
  }
345
198
 
346
- try {
347
- // save cursor, clear scrollback, restore cursor
348
- // this prevents the "jump to top" glitch
349
- originalWrite(CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE);
350
- renderCount = 0;
351
- pendingClear = false;
352
- log('scrollback cleared (periodic)');
353
- } catch (e) {
354
- // stdout might be destroyed
199
+ if (originalWrite && process.stdout.isTTY) {
200
+ // use setImmediate to not block the event loop
201
+ setImmediate(() => {
202
+ log('executing deferred scrollback clear');
203
+ originalWrite(CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE);
204
+ });
355
205
  }
356
206
  }
357
207
 
358
208
  /**
359
- * install the fix
209
+ * installs the fix - hooks into stdout and sigwinch
360
210
  * call this once at startup, calling again is a no-op
361
211
  */
362
212
  function install() {
@@ -365,8 +215,11 @@ function install() {
365
215
  return;
366
216
  }
367
217
 
368
- // Resource limiting — cap CPU & RAM based on user config
369
- installResourceLimiter();
218
+ // only run on supported terminals
219
+ if (!isTerminalSupported()) {
220
+ log('terminal not supported: ' + (process.env.TERM || 'unknown') + ' - skipping install');
221
+ return;
222
+ }
370
223
 
371
224
  originalWrite = process.stdout.write.bind(process.stdout);
372
225
 
@@ -388,112 +241,117 @@ function install() {
388
241
  return originalWrite(chunk, encoding, callback);
389
242
  }
390
243
 
391
- // strip background colors if enabled
392
- if (config.stripBgColors || config.stripColors) {
244
+ renderCount++;
245
+
246
+ // strip colors that cause VTE rendering glitches
247
+ if (config.stripBgColors) {
393
248
  chunk = stripBackgroundColors(chunk);
394
249
  }
395
- }
396
250
 
397
- renderCount++;
251
+ // ink clears screen before re-render, we piggyback on that
252
+ // but only if not actively typing
253
+ if (chunk.includes(CLEAR_SCREEN) || chunk.includes(HOME_CURSOR)) {
254
+ if (config.clearAfterRenders > 0 && renderCount >= config.clearAfterRenders) {
255
+ if (!isTypingActive()) {
256
+ log('clearing scrollback after ' + renderCount + ' renders');
257
+ renderCount = 0;
258
+ chunk = CLEAR_SCROLLBACK + chunk;
259
+ } else {
260
+ log('skipping render-based clear - typing active');
261
+ }
262
+ }
263
+ }
398
264
 
399
- // check if we should clear scrollback
400
- if (renderCount >= config.clearAfterRenders && !isTypingActive()) {
401
- setImmediate(safeClearScrollback);
265
+ // /clear command should actually clear everything (immediate, user-requested)
266
+ if (chunk.includes('Conversation cleared') || chunk.includes('Chat cleared')) {
267
+ log('/clear detected, nuking scrollback');
268
+ chunk = CLEAR_SCROLLBACK + chunk;
269
+ }
402
270
  }
403
271
 
404
272
  return originalWrite(chunk, encoding, callback);
405
273
  };
406
274
 
407
- // periodic scrollback clearing
408
- clearIntervalId = setInterval(() => {
409
- if (pendingClear || renderCount > 100) {
410
- setImmediate(safeClearScrollback);
411
- }
412
- }, config.periodicClearMs);
275
+ // debounce resize events - tmux users know the pain
276
+ installResizeDebounce();
413
277
 
414
- // don't let the interval keep the process alive
415
- if (clearIntervalId && clearIntervalId.unref) {
416
- clearIntervalId.unref();
278
+ // periodic cleanup so long sessions dont get cooked
279
+ // uses safeClearScrollback which respects typing activity
280
+ if (config.periodicClearMs > 0) {
281
+ clearIntervalId = setInterval(() => {
282
+ log('periodic clear check');
283
+ safeClearScrollback();
284
+ }, config.periodicClearMs);
417
285
  }
418
286
 
419
- // install resize debouncing
420
- installResizeDebounce();
421
-
422
287
  installed = true;
423
- log('installed (periodic=' + config.periodicClearMs + 'ms, renders=' + config.clearAfterRenders +
424
- ', heap=' + MAX_HEAP_MB + 'MB, cpu=' + (resourceConfig.cpuPercent || 'unlimited') + ')');
288
+ const mode = config.stripBgColors ? 'bg+dim colors stripped' : 'all colors preserved';
289
+ log('installed successfully - v2.3.1 - ' + mode + ' - TERM=' + process.env.TERM);
425
290
  }
426
291
 
427
- /**
428
- * debounce SIGWINCH events
429
- * tmux/screen fire these like crazy during resize
430
- */
431
292
  function installResizeDebounce() {
432
- const originalListeners = process.listeners('SIGWINCH');
293
+ const originalOn = process.on.bind(process);
294
+ let sigwinchHandlers = [];
433
295
 
434
296
  function debouncedSigwinch() {
435
297
  const now = Date.now();
436
- if (now - lastResizeTime < config.resizeDebounceMs) {
437
- // too fast, schedule for later
438
- clearTimeout(resizeTimeout);
298
+ const timeSince = now - lastResizeTime;
299
+ lastResizeTime = now;
300
+
301
+ if (resizeTimeout) clearTimeout(resizeTimeout);
302
+
303
+ // if events coming too fast, batch em
304
+ if (timeSince < config.resizeDebounceMs) {
439
305
  resizeTimeout = setTimeout(() => {
440
- lastResizeTime = Date.now();
441
- for (const listener of originalListeners) {
442
- listener();
443
- }
306
+ log('firing debounced resize');
307
+ sigwinchHandlers.forEach(h => { try { h(); } catch(e) {} });
444
308
  }, config.resizeDebounceMs);
445
- return;
446
- }
447
- lastResizeTime = now;
448
- for (const listener of originalListeners) {
449
- listener();
309
+ } else {
310
+ sigwinchHandlers.forEach(h => { try { h(); } catch(e) {} });
450
311
  }
451
312
  }
452
313
 
453
- // replace SIGWINCH handler
454
- process.removeAllListeners('SIGWINCH');
455
- process.on('SIGWINCH', debouncedSigwinch);
456
-
457
- // intercept future .on('SIGWINCH') calls
458
- const origOn = process.on.bind(process);
459
314
  process.on = function(event, handler) {
460
315
  if (event === 'SIGWINCH') {
461
- originalListeners.push(handler);
462
- return process;
316
+ sigwinchHandlers.push(handler);
317
+ if (sigwinchHandlers.length === 1) {
318
+ originalOn('SIGWINCH', debouncedSigwinch);
319
+ }
320
+ return this;
463
321
  }
464
- return origOn(event, handler);
322
+ return originalOn(event, handler);
465
323
  };
324
+
325
+ log('resize debounce installed');
466
326
  }
467
327
 
468
328
  /**
469
- * manually clear scrollback (e.g., on /clear command)
329
+ * manually clear scrollback - call this whenever you want
470
330
  */
471
331
  function clearScrollback() {
472
332
  if (originalWrite) {
473
- originalWrite(CLEAR_SCREEN + HOME_CURSOR + CLEAR_SCROLLBACK);
474
- renderCount = 0;
475
- log('scrollback cleared (manual)');
333
+ originalWrite(CLEAR_SCROLLBACK);
334
+ } else {
335
+ process.stdout.write(CLEAR_SCROLLBACK);
476
336
  }
337
+ log('manual scrollback clear');
477
338
  }
478
339
 
479
340
  /**
480
- * get stats about the fix
341
+ * get current stats for debugging
481
342
  */
482
343
  function getStats() {
483
344
  return {
484
- installed,
485
345
  renderCount,
486
346
  lastResizeTime,
487
- config: { ...config },
488
- resourceLimits: {
489
- maxHeapMB: MAX_HEAP_MB,
490
- memPercent: resourceConfig.memPercent,
491
- cpuPercent: resourceConfig.cpuPercent,
492
- totalMemMB: TOTAL_MEM_MB,
493
- }
347
+ installed,
348
+ config
494
349
  };
495
350
  }
496
351
 
352
+ /**
353
+ * update config at runtime
354
+ */
497
355
  function setConfig(key, value) {
498
356
  if (key in config) {
499
357
  config[key] = value;
@@ -507,7 +365,6 @@ function setConfig(key, value) {
507
365
  function disable() {
508
366
  if (originalWrite) {
509
367
  process.stdout.write = originalWrite;
510
- cleanupResourceLimiter();
511
368
  log('disabled');
512
369
  }
513
370
  }
@@ -519,7 +376,5 @@ module.exports = {
519
376
  setConfig,
520
377
  disable,
521
378
  stripColors: stripBackgroundColors,
522
- config,
523
- resourceConfig,
524
- MAX_HEAP_MB
379
+ config
525
380
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claudefix",
3
- "version": "2.7.0",
4
- "description": "Fixes screen glitching, blocky colors, memory leaks AND resource hogging in Claude Code CLI on Linux and macOS. V8 heap capping (--max-old-space-size), forced GC (--expose-gc), CPU limiting (cpulimit/cgroup/nice), RAM monitoring. All configurable via ~/.claudefix.json or env vars. Developed by Hardwick Software Services @ https://justcalljon.pro",
3
+ "version": "2.7.2",
4
+ "description": "Fixes screen glitching, blocky colors, AND MEMORY LEAKS in Claude Code CLI on Linux and macOS. All features optional via env vars. Shows config options on install. Developed by Hardwick Software Services @ https://justcalljon.pro",
5
5
  "main": "index.cjs",
6
6
  "bin": {
7
7
  "claude-fixed": "bin/claude-fixed.js",