claudescreenfix-hardwicksoftware 1.0.0 → 2.0.0

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.
package/README.md CHANGED
@@ -117,6 +117,22 @@ for resize events, we intercept `process.on('SIGWINCH', ...)` and debounce the h
117
117
 
118
118
  bottom line: smooth terminal, no lag, no memory bloat. it just works.
119
119
 
120
+ ## changelog
121
+
122
+ ### v1.0.1 (2025-01-08)
123
+ - **FIXED: typing issue** - keystrokes were getting lost because the fix was intercepting stdin echoes
124
+ - now detects stdin echo writes (single chars, backspace, arrow keys) and passes them through unmodified
125
+ - added typing cooldown detection - clears are deferred during active typing
126
+ - periodic clears now use `setImmediate` to not block the event loop
127
+ - added stdin tracking to properly detect user input activity
128
+ - new config option: `typingCooldownMs` (default 500ms) - how long to wait after typing before allowing clears
129
+
130
+ ### v1.0.0 (2025-01-08)
131
+ - initial release
132
+ - scrollback clearing after 500 renders or 60 seconds
133
+ - SIGWINCH debouncing for tmux/screen users
134
+ - enhanced /clear command to actually clear scrollback
135
+
120
136
  ## known issues
121
137
 
122
138
  - some old terminals don't support `\x1b[3J` but that's pretty rare nowadays
@@ -5,14 +5,15 @@
5
5
  * wrapper script - runs claude with the terminal fix loaded
6
6
  *
7
7
  * finds your claude binary and runs it with our fix injected
8
- * no manual setup needed, just run claude-fixed instead of claude
8
+ * you don't need any manual setup, just run claude-fixed instead of claude
9
+ * it'll handle the rest
9
10
  */
10
11
 
11
12
  const { spawn, execSync } = require('child_process');
12
13
  const path = require('path');
13
14
  const fs = require('fs');
14
15
 
15
- // find the loader path
16
+ // find the loader path - it's in the parent dir
16
17
  const loaderPath = path.join(__dirname, '..', 'loader.cjs');
17
18
 
18
19
  if (!fs.existsSync(loaderPath)) {
@@ -25,11 +26,11 @@ let claudeBin;
25
26
  try {
26
27
  claudeBin = execSync('which claude', { encoding: 'utf8' }).trim();
27
28
  } catch (e) {
28
- console.error('claude not found in PATH - make sure its installed');
29
+ console.error('claude not found in PATH - make sure it\'s installed');
29
30
  process.exit(1);
30
31
  }
31
32
 
32
- // run claude with our fix loaded via NODE_OPTIONS
33
+ // run claude with our fix loaded via NODE_OPTIONS - it's the cleanest way
33
34
  const env = Object.assign({}, process.env, {
34
35
  NODE_OPTIONS: '--require ' + loaderPath + ' ' + (process.env.NODE_OPTIONS || '')
35
36
  });
@@ -0,0 +1,351 @@
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
+ };
package/index.cjs CHANGED
@@ -4,15 +4,24 @@
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 dont clear scrollback
8
- * so after like 30 min your terminal got thousands of lines in the buffer
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
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 aint thrashing
14
+ * - debounce SIGWINCH so resize ain't thrashing
15
15
  * - enhance /clear to actually clear scrollback not just the screen
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
20
+ *
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
16
25
  */
17
26
 
18
27
  const CLEAR_SCROLLBACK = '\x1b[3J';
@@ -21,11 +30,25 @@ const CURSOR_RESTORE = '\x1b[u';
21
30
  const CLEAR_SCREEN = '\x1b[2J';
22
31
  const HOME_CURSOR = '\x1b[H';
23
32
 
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
+ }
43
+
24
44
  // config - tweak these if needed
25
45
  const config = {
26
46
  resizeDebounceMs: 150, // how long to wait before firing resize
27
47
  periodicClearMs: 60000, // clear scrollback every 60s
28
48
  clearAfterRenders: 500, // or after 500 render cycles
49
+ 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
29
52
  debug: process.env.CLAUDE_TERMINAL_FIX_DEBUG === '1',
30
53
  disabled: process.env.CLAUDE_TERMINAL_FIX_DISABLED === '1'
31
54
  };
@@ -36,6 +59,11 @@ let lastResizeTime = 0;
36
59
  let resizeTimeout = null;
37
60
  let originalWrite = null;
38
61
  let installed = false;
62
+ let lastTypingTime = 0; // track when user last typed
63
+ let pendingClear = false; // defer clear if typing active
64
+ let clearIntervalId = null;
65
+ let lineCount = 0; // NEW: track output line count for 120-line limit
66
+ let glitchRecoveryInProgress = false; // NEW: prevent recovery loops
39
67
 
40
68
  function log(...args) {
41
69
  if (config.debug) {
@@ -43,9 +71,68 @@ function log(...args) {
43
71
  }
44
72
  }
45
73
 
74
+ /**
75
+ * check if user's actively typing (within cooldown window)
76
+ */
77
+ function isTypingActive() {
78
+ return (Date.now() - lastTypingTime) < config.typingCooldownMs;
79
+ }
80
+
81
+ /**
82
+ * 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
85
+ */
86
+ function isStdinEcho(chunk) {
87
+ // single printable character (including space)
88
+ if (chunk.length === 1 && chunk.charCodeAt(0) >= 32 && chunk.charCodeAt(0) <= 126) {
89
+ return true;
90
+ }
91
+ // backspace/delete echo (usually 1-3 chars with control codes)
92
+ if (chunk.length <= 4 && (chunk.includes('\b') || chunk.includes('\x7f'))) {
93
+ return true;
94
+ }
95
+ // arrow key echo or cursor movement (short escape sequences)
96
+ if (chunk.length <= 6 && chunk.startsWith('\x1b[') && !chunk.includes('J') && !chunk.includes('H')) {
97
+ return true;
98
+ }
99
+ // enter/newline
100
+ if (chunk === '\n' || chunk === '\r' || chunk === '\r\n') {
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * safe clear - defers if typing's active so we don't eat keystrokes
108
+ */
109
+ function safeClearScrollback() {
110
+ if (isTypingActive()) {
111
+ if (!pendingClear) {
112
+ pendingClear = true;
113
+ log('deferring clear - typing active');
114
+ setTimeout(() => {
115
+ pendingClear = false;
116
+ if (!isTypingActive()) {
117
+ safeClearScrollback();
118
+ }
119
+ }, config.typingCooldownMs);
120
+ }
121
+ return;
122
+ }
123
+
124
+ if (originalWrite && process.stdout.isTTY) {
125
+ // use setImmediate to not block the event loop
126
+ setImmediate(() => {
127
+ log('executing deferred scrollback clear');
128
+ originalWrite(CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE);
129
+ });
130
+ }
131
+ }
132
+
46
133
  /**
47
134
  * installs the fix - hooks into stdout and sigwinch
48
- * call this once at startup, calling again is a no-op
135
+ * call this once at startup, calling again won't do anything
49
136
  */
50
137
  function install() {
51
138
  if (installed || config.disabled) {
@@ -55,25 +142,86 @@ function install() {
55
142
 
56
143
  originalWrite = process.stdout.write.bind(process.stdout);
57
144
 
145
+ // track stdin to know when user is typing
146
+ if (process.stdin.isTTY) {
147
+ process.stdin.on('data', () => {
148
+ lastTypingTime = Date.now();
149
+ });
150
+ }
151
+
58
152
  // hook stdout.write - this is where the magic happens
59
153
  process.stdout.write = function(chunk, encoding, callback) {
154
+ // CRITICAL FIX: pass stdin echoes through unmodified
155
+ // this prevents the typing issue where keystrokes get lost
60
156
  if (typeof chunk === 'string') {
157
+ // check if this is a stdin echo - if so, pass through immediately
158
+ if (isStdinEcho(chunk)) {
159
+ lastTypingTime = Date.now(); // update typing time
160
+ return originalWrite(chunk, encoding, callback);
161
+ }
162
+
61
163
  renderCount++;
62
164
 
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;
179
+ }
180
+
63
181
  // ink clears screen before re-render, we piggyback on that
182
+ // but only if not actively typing
64
183
  if (chunk.includes(CLEAR_SCREEN) || chunk.includes(HOME_CURSOR)) {
184
+ lineCount = 0; // Reset line count on screen clear
65
185
  if (config.clearAfterRenders > 0 && renderCount >= config.clearAfterRenders) {
66
- log('clearing scrollback after ' + renderCount + ' renders');
67
- renderCount = 0;
68
- chunk = CLEAR_SCROLLBACK + chunk;
186
+ if (!isTypingActive()) {
187
+ log('clearing scrollback after ' + renderCount + ' renders');
188
+ renderCount = 0;
189
+ chunk = CLEAR_SCROLLBACK + chunk;
190
+ } else {
191
+ log('skipping render-based clear - typing active');
192
+ }
69
193
  }
70
194
  }
71
195
 
72
- // /clear command should actually clear everything
196
+ // /clear should actually clear everything (it's user-requested so do it now)
73
197
  if (chunk.includes('Conversation cleared') || chunk.includes('Chat cleared')) {
74
198
  log('/clear detected, nuking scrollback');
199
+ lineCount = 0;
75
200
  chunk = CLEAR_SCROLLBACK + chunk;
76
201
  }
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
+ }
77
225
  }
78
226
 
79
227
  return originalWrite(chunk, encoding, callback);
@@ -83,17 +231,36 @@ function install() {
83
231
  installResizeDebounce();
84
232
 
85
233
  // periodic cleanup so long sessions dont get cooked
234
+ // uses safeClearScrollback which respects typing activity
86
235
  if (config.periodicClearMs > 0) {
87
- setInterval(() => {
88
- if (process.stdout.isTTY) {
89
- log('periodic scrollback clear');
90
- originalWrite(CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE);
91
- }
236
+ clearIntervalId = setInterval(() => {
237
+ log('periodic clear check');
238
+ safeClearScrollback();
92
239
  }, config.periodicClearMs);
93
240
  }
94
241
 
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
+
95
262
  installed = true;
96
- log('installed successfully');
263
+ log('installed successfully - v2.0.0 with glitch detection & 120-line limit');
97
264
  }
98
265
 
99
266
  function installResizeDebounce() {
@@ -133,7 +300,7 @@ function installResizeDebounce() {
133
300
  }
134
301
 
135
302
  /**
136
- * manually clear scrollback - call this whenever you want
303
+ * manually clear scrollback - call this whenever you want, it won't break anything
137
304
  */
138
305
  function clearScrollback() {
139
306
  if (originalWrite) {
@@ -148,12 +315,40 @@ function clearScrollback() {
148
315
  * get current stats for debugging
149
316
  */
150
317
  function getStats() {
151
- return {
318
+ const stats = {
152
319
  renderCount,
320
+ lineCount,
153
321
  lastResizeTime,
154
322
  installed,
155
323
  config
156
324
  };
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;
157
352
  }
158
353
 
159
354
  /**
@@ -167,7 +362,7 @@ function setConfig(key, value) {
167
362
  }
168
363
 
169
364
  /**
170
- * disable the fix (mostly for testing)
365
+ * disable the fix (it's mostly for testing)
171
366
  */
172
367
  function disable() {
173
368
  if (originalWrite) {
@@ -182,5 +377,9 @@ module.exports = {
182
377
  getStats,
183
378
  setConfig,
184
379
  disable,
185
- config
380
+ config,
381
+ // NEW v2.0 exports
382
+ forceRecovery,
383
+ isGlitched,
384
+ getDetector: () => glitchDetector
186
385
  };
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
- * no code changes needed in claude itself
9
+ * you don't need to change any code in claude itself - it just works
10
10
  */
11
11
 
12
12
  const fix = require('./index.cjs');
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "claudescreenfix-hardwicksoftware",
3
- "version": "1.0.0",
4
- "description": "fixes the scroll glitch in claude code cli - unbounded scrollback, resize thrashing, all that bs",
3
+ "version": "2.0.0",
4
+ "description": "fixes the scroll glitch in claude code cli - now with GLITCH DETECTION, 120-line limit enforcement, and auto-recovery",
5
5
  "main": "index.cjs",
6
6
  "bin": {
7
7
  "claude-fixed": "./bin/claude-fixed.js"
8
8
  },
9
- "scripts": {},
9
+ "scripts": {
10
+ "test": "node -e \"const fix = require('./index.cjs'); fix.install(); console.log(fix.getStats());\""
11
+ },
10
12
  "keywords": [
11
13
  "claude",
12
14
  "terminal",
@@ -15,7 +17,10 @@
15
17
  "ink",
16
18
  "cli",
17
19
  "glitch",
18
- "performance"
20
+ "performance",
21
+ "glitch-detection",
22
+ "recovery",
23
+ "120-line-limit"
19
24
  ],
20
25
  "author": "jonhardwick-spec",
21
26
  "license": "MIT",
@@ -32,6 +37,7 @@
32
37
  "files": [
33
38
  "index.cjs",
34
39
  "loader.cjs",
40
+ "glitch-detector.cjs",
35
41
  "bin/",
36
42
  "README.md",
37
43
  "LICENSE"