claudescreenfix-hardwicksoftware 2.0.0 → 2.2.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.
@@ -5,15 +5,14 @@
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
- * you don't need any manual setup, just run claude-fixed instead of claude
9
- * it'll handle the rest
8
+ * no manual setup needed, just run claude-fixed instead of claude
10
9
  */
11
10
 
12
11
  const { spawn, execSync } = require('child_process');
13
12
  const path = require('path');
14
13
  const fs = require('fs');
15
14
 
16
- // find the loader path - it's in the parent dir
15
+ // find the loader path
17
16
  const loaderPath = path.join(__dirname, '..', 'loader.cjs');
18
17
 
19
18
  if (!fs.existsSync(loaderPath)) {
@@ -26,11 +25,11 @@ let claudeBin;
26
25
  try {
27
26
  claudeBin = execSync('which claude', { encoding: 'utf8' }).trim();
28
27
  } catch (e) {
29
- console.error('claude not found in PATH - make sure it\'s installed');
28
+ console.error('claude not found in PATH - make sure its installed');
30
29
  process.exit(1);
31
30
  }
32
31
 
33
- // run claude with our fix loaded via NODE_OPTIONS - it's the cleanest way
32
+ // run claude with our fix loaded via NODE_OPTIONS
34
33
  const env = Object.assign({}, process.env, {
35
34
  NODE_OPTIONS: '--require ' + loaderPath + ' ' + (process.env.NODE_OPTIONS || '')
36
35
  });
package/index.cjs CHANGED
@@ -4,42 +4,44 @@
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
+ // ONLY background colors - foreground colors stay intact
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
+ ];
43
45
 
44
46
  // config - tweak these if needed
45
47
  const config = {
@@ -47,10 +49,11 @@ const config = {
47
49
  periodicClearMs: 60000, // clear scrollback every 60s
48
50
  clearAfterRenders: 500, // or after 500 render cycles
49
51
  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
52
  debug: process.env.CLAUDE_TERMINAL_FIX_DEBUG === '1',
53
- disabled: process.env.CLAUDE_TERMINAL_FIX_DISABLED === '1'
53
+ disabled: process.env.CLAUDE_TERMINAL_FIX_DISABLED === '1',
54
+ headlessMode: null, // auto-detect, or force with env var
55
+ forceHeadless: process.env.CLAUDE_HEADLESS_MODE === '1',
56
+ forceNoHeadless: process.env.CLAUDE_HEADLESS_MODE === '0',
54
57
  };
55
58
 
56
59
  // state tracking
@@ -62,8 +65,7 @@ let installed = false;
62
65
  let lastTypingTime = 0; // track when user last typed
63
66
  let pendingClear = false; // defer clear if typing active
64
67
  let clearIntervalId = null;
65
- let lineCount = 0; // NEW: track output line count for 120-line limit
66
- let glitchRecoveryInProgress = false; // NEW: prevent recovery loops
68
+ let headlessDetected = null; // cached headless detection result
67
69
 
68
70
  function log(...args) {
69
71
  if (config.debug) {
@@ -72,7 +74,75 @@ function log(...args) {
72
74
  }
73
75
 
74
76
  /**
75
- * check if user's actively typing (within cooldown window)
77
+ * detect if we're running in a headless/VNC/Xvfb environment
78
+ * VTE (xfce4-terminal, gnome-terminal) has rendering bugs on Xvfb
79
+ * where background colors get drawn ON TOP of text instead of behind
80
+ */
81
+ function isHeadless() {
82
+ // use cached result if available
83
+ if (headlessDetected !== null) return headlessDetected;
84
+
85
+ // check env var overrides first
86
+ if (config.forceHeadless) {
87
+ log('headless mode forced ON via env var');
88
+ headlessDetected = true;
89
+ return true;
90
+ }
91
+ if (config.forceNoHeadless) {
92
+ log('headless mode forced OFF via env var');
93
+ headlessDetected = false;
94
+ return false;
95
+ }
96
+
97
+ // no DISPLAY = definitely headless (but probably no terminal anyway)
98
+ const display = process.env.DISPLAY;
99
+ if (!display) {
100
+ headlessDetected = true;
101
+ return true;
102
+ }
103
+
104
+ try {
105
+ // check if Xvfb is the display server
106
+ const xdpyinfo = execSync(`xdpyinfo -display ${display} 2>/dev/null`, { encoding: 'utf8', timeout: 2000 });
107
+ if (xdpyinfo.toLowerCase().includes('xvfb') || xdpyinfo.toLowerCase().includes('virtual')) {
108
+ log('detected Xvfb/virtual display');
109
+ headlessDetected = true;
110
+ return true;
111
+ }
112
+
113
+ // check for x11vnc or other VNC on this display
114
+ const vnc = execSync(`pgrep -a "x11vnc|Xvnc|vncserver" 2>/dev/null || true`, { encoding: 'utf8', timeout: 2000 });
115
+ if (vnc.includes(display) || vnc.includes('x11vnc')) {
116
+ log('detected VNC server');
117
+ headlessDetected = true;
118
+ return true;
119
+ }
120
+ } catch (e) {
121
+ // if we can't detect, assume not headless
122
+ log('headless detection failed, assuming normal display');
123
+ }
124
+
125
+ headlessDetected = false;
126
+ return false;
127
+ }
128
+
129
+ /**
130
+ * strip background colors from output for headless mode
131
+ * keeps foreground colors, cursor movement, and everything else
132
+ * this fixes VTE rendering glitches where BG colors overlay text
133
+ */
134
+ function stripBackgroundColors(chunk) {
135
+ if (typeof chunk !== 'string') return chunk;
136
+
137
+ let result = chunk;
138
+ for (const pattern of ANSI_BG_PATTERNS) {
139
+ result = result.replace(pattern, '');
140
+ }
141
+ return result;
142
+ }
143
+
144
+ /**
145
+ * check if user is actively typing (within cooldown window)
76
146
  */
77
147
  function isTypingActive() {
78
148
  return (Date.now() - lastTypingTime) < config.typingCooldownMs;
@@ -80,8 +150,7 @@ function isTypingActive() {
80
150
 
81
151
  /**
82
152
  * 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
153
+ * stdin echoes are typically: single chars, backspace sequences, arrow key echoes
85
154
  */
86
155
  function isStdinEcho(chunk) {
87
156
  // single printable character (including space)
@@ -104,7 +173,7 @@ function isStdinEcho(chunk) {
104
173
  }
105
174
 
106
175
  /**
107
- * safe clear - defers if typing's active so we don't eat keystrokes
176
+ * safe clear - defers if typing active
108
177
  */
109
178
  function safeClearScrollback() {
110
179
  if (isTypingActive()) {
@@ -132,7 +201,7 @@ function safeClearScrollback() {
132
201
 
133
202
  /**
134
203
  * installs the fix - hooks into stdout and sigwinch
135
- * call this once at startup, calling again won't do anything
204
+ * call this once at startup, calling again is a no-op
136
205
  */
137
206
  function install() {
138
207
  if (installed || config.disabled) {
@@ -162,26 +231,15 @@ function install() {
162
231
 
163
232
  renderCount++;
164
233
 
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;
234
+ // HEADLESS MODE: strip background colors that cause VTE glitches
235
+ // keeps foreground colors, spinners, everything else working
236
+ if (isHeadless()) {
237
+ chunk = stripBackgroundColors(chunk);
179
238
  }
180
239
 
181
240
  // ink clears screen before re-render, we piggyback on that
182
241
  // but only if not actively typing
183
242
  if (chunk.includes(CLEAR_SCREEN) || chunk.includes(HOME_CURSOR)) {
184
- lineCount = 0; // Reset line count on screen clear
185
243
  if (config.clearAfterRenders > 0 && renderCount >= config.clearAfterRenders) {
186
244
  if (!isTypingActive()) {
187
245
  log('clearing scrollback after ' + renderCount + ' renders');
@@ -193,35 +251,11 @@ function install() {
193
251
  }
194
252
  }
195
253
 
196
- // /clear should actually clear everything (it's user-requested so do it now)
254
+ // /clear command should actually clear everything (immediate, user-requested)
197
255
  if (chunk.includes('Conversation cleared') || chunk.includes('Chat cleared')) {
198
256
  log('/clear detected, nuking scrollback');
199
- lineCount = 0;
200
257
  chunk = CLEAR_SCROLLBACK + chunk;
201
258
  }
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
259
  }
226
260
 
227
261
  return originalWrite(chunk, encoding, callback);
@@ -239,28 +273,9 @@ function install() {
239
273
  }, config.periodicClearMs);
240
274
  }
241
275
 
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
276
  installed = true;
263
- log('installed successfully - v2.0.0 with glitch detection & 120-line limit');
277
+ const mode = isHeadless() ? 'HEADLESS MODE (stripping BG colors)' : 'normal mode';
278
+ log('installed successfully - v2.2.0 with headless fix - ' + mode);
264
279
  }
265
280
 
266
281
  function installResizeDebounce() {
@@ -300,7 +315,7 @@ function installResizeDebounce() {
300
315
  }
301
316
 
302
317
  /**
303
- * manually clear scrollback - call this whenever you want, it won't break anything
318
+ * manually clear scrollback - call this whenever you want
304
319
  */
305
320
  function clearScrollback() {
306
321
  if (originalWrite) {
@@ -315,40 +330,12 @@ function clearScrollback() {
315
330
  * get current stats for debugging
316
331
  */
317
332
  function getStats() {
318
- const stats = {
333
+ return {
319
334
  renderCount,
320
- lineCount,
321
335
  lastResizeTime,
322
336
  installed,
323
337
  config
324
338
  };
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
339
  }
353
340
 
354
341
  /**
@@ -362,7 +349,7 @@ function setConfig(key, value) {
362
349
  }
363
350
 
364
351
  /**
365
- * disable the fix (it's mostly for testing)
352
+ * disable the fix (mostly for testing)
366
353
  */
367
354
  function disable() {
368
355
  if (originalWrite) {
@@ -377,9 +364,7 @@ module.exports = {
377
364
  getStats,
378
365
  setConfig,
379
366
  disable,
380
- config,
381
- // NEW v2.0 exports
382
- forceRecovery,
383
- isGlitched,
384
- getDetector: () => glitchDetector
367
+ isHeadless,
368
+ stripBackgroundColors,
369
+ config
385
370
  };
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,14 +1,12 @@
1
1
  {
2
2
  "name": "claudescreenfix-hardwicksoftware",
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",
3
+ "version": "2.2.0",
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 -e \"const fix = require('./index.cjs'); fix.install(); console.log(fix.getStats());\""
11
- },
9
+ "scripts": {},
12
10
  "keywords": [
13
11
  "claude",
14
12
  "terminal",
@@ -17,10 +15,7 @@
17
15
  "ink",
18
16
  "cli",
19
17
  "glitch",
20
- "performance",
21
- "glitch-detection",
22
- "recovery",
23
- "120-line-limit"
18
+ "performance"
24
19
  ],
25
20
  "author": "jonhardwick-spec",
26
21
  "license": "MIT",
@@ -37,7 +32,6 @@
37
32
  "files": [
38
33
  "index.cjs",
39
34
  "loader.cjs",
40
- "glitch-detector.cjs",
41
35
  "bin/",
42
36
  "README.md",
43
37
  "LICENSE"
@@ -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
- };