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.
- package/bin/claude-fixed.js +4 -5
- package/index.cjs +112 -127
- package/loader.cjs +1 -1
- package/package.json +4 -10
- package/glitch-detector.cjs +0 -351
package/bin/claude-fixed.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
8
|
-
* so after like 30 min your terminal
|
|
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
|
|
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:
|
|
18
|
-
* - stdin
|
|
19
|
-
* - clears
|
|
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.
|
|
22
|
-
* -
|
|
23
|
-
* -
|
|
24
|
-
* -
|
|
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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
*
|
|
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.
|
|
4
|
-
"description": "fixes
|
|
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"
|
package/glitch-detector.cjs
DELETED
|
@@ -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
|
-
};
|