mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
525f02f502 | ||
|
|
e07c59477d | ||
|
|
2b8a522cbd | ||
|
|
4abe055182 | ||
|
|
15a3b3996b | ||
|
|
1b76e6e2e2 |
@@ -1,5 +1,17 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Chrome "page unresponsive" crashes caused by xterm.js WebGL renderer GPU stalls during heavy terminal output. Disable WebGL by default (canvas renderer used instead), gate SSE terminal writes during tab switches, and add crash diagnostics with server-side breadcrumb collection.
|
||||
|
||||
## 0.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Chrome tab freeze from flicker filter buffer accumulation during active sessions, and fix shell mode feedback delay by excluding shell sessions from cursor-up filter
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.3.3 (must match `package.json`)
|
||||
**Version**: 0.3.5 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.5",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ appendFileSync(
|
||||
);
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
|
||||
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
|
||||
|
||||
|
||||
+178
-17
@@ -78,6 +78,82 @@
|
||||
// MobileDetection, KeyboardHandler, SwipeHandler are in mobile-handlers.js
|
||||
// DeepgramProvider, VoiceInput are in voice-input.js
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Global Error & Performance Diagnostics
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Writes breadcrumbs to localStorage so they survive tab freezes.
|
||||
// After a crash, check: localStorage.getItem('codeman-crash-diag')
|
||||
|
||||
const _crashDiag = {
|
||||
_entries: [],
|
||||
_maxEntries: 50,
|
||||
log(msg) {
|
||||
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
|
||||
this._entries.push(entry);
|
||||
if (this._entries.length > this._maxEntries) this._entries.shift();
|
||||
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Log previous crash breadcrumbs on startup
|
||||
try {
|
||||
const prev = localStorage.getItem('codeman-crash-diag');
|
||||
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
|
||||
} catch {}
|
||||
_crashDiag.log('PAGE LOAD');
|
||||
|
||||
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
|
||||
setInterval(() => {
|
||||
try {
|
||||
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
|
||||
if (_crashDiag._entries.length > 0) {
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
|
||||
}
|
||||
} catch {}
|
||||
}, 2000);
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
|
||||
console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
_crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`);
|
||||
console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack);
|
||||
});
|
||||
|
||||
// Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive"
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
try {
|
||||
const longTaskObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration > 200) {
|
||||
_crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`);
|
||||
console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
longTaskObserver.observe({ type: 'longtask', buffered: true });
|
||||
} catch { /* longtask not supported */ }
|
||||
}
|
||||
|
||||
// Track WebGL context loss/restore events on all canvases
|
||||
const _origGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function(type, ...args) {
|
||||
const ctx = _origGetContext.call(this, type, ...args);
|
||||
if (type === 'webgl2' || type === 'webgl') {
|
||||
this.addEventListener('webglcontextlost', (e) => {
|
||||
_crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`);
|
||||
console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented);
|
||||
});
|
||||
this.addEventListener('webglcontextrestored', () => {
|
||||
_crashDiag.log('WEBGL_RESTORED');
|
||||
console.warn('[CRASH-DIAG] WebGL context restored');
|
||||
});
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -542,14 +618,23 @@ class CodemanApp {
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
|
||||
// Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure).
|
||||
// Store reference so we can disable during large buffer loads to prevent GPU stalls.
|
||||
// WebGL renderer disabled — canvas renderer used instead.
|
||||
// xterm.js WebGL addon causes synchronous GPU ReadPixels calls during large
|
||||
// terminal writes (buffer loads, heavy Ink output) that block Chrome's main
|
||||
// thread for 10+ seconds, triggering "page unresponsive" crashes.
|
||||
// Canvas renderer handles the same workloads without GPU stalls.
|
||||
// Re-enable with ?webgl=1 URL param for testing.
|
||||
this._webglAddon = null;
|
||||
if (typeof WebglAddon !== 'undefined') {
|
||||
if (new URLSearchParams(location.search).has('webgl') && typeof WebglAddon !== 'undefined') {
|
||||
try {
|
||||
this._webglAddon = new WebglAddon.WebglAddon();
|
||||
this._webglAddon.onContextLoss(() => { this._webglAddon.dispose(); this._webglAddon = null; });
|
||||
this._webglAddon.onContextLoss(() => {
|
||||
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
|
||||
this._webglAddon.dispose();
|
||||
this._webglAddon = null;
|
||||
});
|
||||
this.terminal.loadAddon(this._webglAddon);
|
||||
console.log('[CRASH-DIAG] WebGL renderer enabled via ?webgl param');
|
||||
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
|
||||
}
|
||||
|
||||
@@ -1097,18 +1182,47 @@ class CodemanApp {
|
||||
// Ink's status bar updates use cursor-up + erase-line + rewrite, which can split
|
||||
// across render frames causing old/new status text to overlap (garbled output).
|
||||
// Buffering for 50ms ensures the full redraw arrives atomically.
|
||||
const hasCursorUpRedraw = /\x1b\[\d{1,2}A/.test(data);
|
||||
//
|
||||
// Shell mode is excluded: shell readline also uses cursor-up for prompt redraws
|
||||
// (e.g. zsh syntax highlighting on every keystroke), and there's no Ink status bar
|
||||
// to protect. Applying the filter in shell mode delays character feedback until the
|
||||
// user stops typing for 50ms, making the terminal feel unresponsive.
|
||||
const isShellMode = session?.mode === 'shell';
|
||||
const hasCursorUpRedraw = !isShellMode && /\x1b\[\d{1,2}A/.test(data);
|
||||
if (hasCursorUpRedraw || (this.flickerFilterActive && !flickerFilterEnabled)) {
|
||||
this.flickerFilterActive = true;
|
||||
this.flickerFilterBuffer += data;
|
||||
|
||||
if (this.flickerFilterTimeout) {
|
||||
clearTimeout(this.flickerFilterTimeout);
|
||||
// Only reset the 50ms timer on cursor-up events (start of a new Ink redraw cycle).
|
||||
// Non-cursor-up events while the filter is active are trailing data from the same
|
||||
// redraw — don't extend the deadline further. Without this guard, a busy Claude
|
||||
// session emitting terminal data faster than SYNC_WAIT_TIMEOUT_MS never flushes,
|
||||
// accumulating MBs in flickerFilterBuffer that freeze Chrome all at once.
|
||||
if (hasCursorUpRedraw) {
|
||||
if (this.flickerFilterTimeout) {
|
||||
clearTimeout(this.flickerFilterTimeout);
|
||||
}
|
||||
this.flickerFilterTimeout = setTimeout(() => {
|
||||
this.flickerFilterTimeout = null;
|
||||
this.flushFlickerBuffer();
|
||||
}, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window
|
||||
} else if (!this.flickerFilterTimeout) {
|
||||
// Safety: if no timer is running for some reason, ensure we eventually flush.
|
||||
this.flickerFilterTimeout = setTimeout(() => {
|
||||
this.flickerFilterTimeout = null;
|
||||
this.flushFlickerBuffer();
|
||||
}, SYNC_WAIT_TIMEOUT_MS);
|
||||
}
|
||||
this.flickerFilterTimeout = setTimeout(() => {
|
||||
this.flickerFilterTimeout = null;
|
||||
|
||||
// Safety valve: if buffer grew very large (e.g. from a burst before the timer fired),
|
||||
// flush immediately to avoid writing a huge block all at once.
|
||||
if (this.flickerFilterBuffer.length > 256 * 1024) {
|
||||
if (this.flickerFilterTimeout) {
|
||||
clearTimeout(this.flickerFilterTimeout);
|
||||
this.flickerFilterTimeout = null;
|
||||
}
|
||||
this.flushFlickerBuffer();
|
||||
}, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1238,6 +1352,11 @@ class CodemanApp {
|
||||
} catch { return null; }
|
||||
}
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
@@ -1252,13 +1371,18 @@ class CodemanApp {
|
||||
flushPendingWrites() {
|
||||
if (this.pendingWrites.length === 0 || !this.terminal) return;
|
||||
|
||||
const _t0 = performance.now();
|
||||
// Extract segments, stripping DEC 2026 markers
|
||||
// This implements synchronized output for xterm.js which doesn't support DEC 2026 natively
|
||||
const segments = extractSyncSegments(this.pendingWrites.join(''));
|
||||
const _joinedLen = this.pendingWrites.reduce((s, w) => s + w.length, 0);
|
||||
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
|
||||
const joined = this.pendingWrites.join('');
|
||||
this.pendingWrites = [];
|
||||
|
||||
// Write all segments in a single batch (atomic within this frame)
|
||||
// xterm.js internally batches multiple write() calls within same frame
|
||||
const segments = extractSyncSegments(joined);
|
||||
|
||||
// Write all segments in a single batch (atomic within this frame).
|
||||
// xterm.js internally batches multiple write() calls within same frame.
|
||||
// Never discard content from incomplete sync blocks — xterm.js doesn't support
|
||||
// DEC 2026 natively anyway, so strip the marker and write content regardless.
|
||||
// Discarding causes real data loss (including Ink's erase-line escapes).
|
||||
@@ -1270,6 +1394,8 @@ class CodemanApp {
|
||||
if (content) this.terminal.write(content);
|
||||
}
|
||||
}
|
||||
const _dt = performance.now() - _t0;
|
||||
if (_dt > 100) console.warn(`[CRASH-DIAG] flushPendingWrites took ${_dt.toFixed(0)}ms (${_joinedLen} bytes)`);
|
||||
|
||||
// Sticky scroll: if user was at bottom, keep them there after new output
|
||||
if (this._wasAtBottomBeforeWrite) {
|
||||
@@ -1362,15 +1488,23 @@ class CodemanApp {
|
||||
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
|
||||
// avoiding GPU stalls without needing to toggle the renderer.
|
||||
let offset = 0;
|
||||
const _chunkStart = performance.now();
|
||||
let _chunkCount = 0;
|
||||
const writeChunk = () => {
|
||||
if (offset >= cleanBuffer.length) {
|
||||
const _totalMs = performance.now() - _chunkStart;
|
||||
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
|
||||
// Wait one more frame for xterm to finish rendering before resolving
|
||||
requestAnimationFrame(finish);
|
||||
return;
|
||||
}
|
||||
|
||||
const _ct0 = performance.now();
|
||||
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
|
||||
this.terminal.write(chunk);
|
||||
const _cdt = performance.now() - _ct0;
|
||||
_chunkCount++;
|
||||
if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`);
|
||||
offset += chunkSize;
|
||||
|
||||
// Schedule next chunk on next frame
|
||||
@@ -1593,6 +1727,7 @@ class CodemanApp {
|
||||
// Async handlers have their own internal try/catch for fetch errors.
|
||||
|
||||
_onInit(data) {
|
||||
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
|
||||
this.handleInit(data);
|
||||
}
|
||||
|
||||
@@ -1651,6 +1786,7 @@ class CodemanApp {
|
||||
|
||||
_onSessionTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
||||
this.batchTerminalWrite(data.data);
|
||||
}
|
||||
}
|
||||
@@ -3312,6 +3448,10 @@ class CodemanApp {
|
||||
|
||||
async selectSession(sessionId) {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
const _selStart = performance.now();
|
||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||
_crashDiag.log(`SELECT: ${_selName}`);
|
||||
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
||||
|
||||
const selectGen = ++this._selectGeneration;
|
||||
|
||||
@@ -3440,23 +3580,33 @@ class CodemanApp {
|
||||
// filter catches most cases, but _restoringFlushedState provides a
|
||||
// belt-and-suspenders guard for any edge cases.
|
||||
this._restoringFlushedState = true;
|
||||
// Gate live SSE terminal writes for the ENTIRE buffer load sequence.
|
||||
// Without this, SSE events arriving during the fetch() gap compete with
|
||||
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
|
||||
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
try {
|
||||
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
|
||||
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
|
||||
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
if (cachedBuffer) {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
await this.chunkedTerminalWrite(cachedBuffer);
|
||||
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
}
|
||||
|
||||
_crashDiag.log('FETCH_START');
|
||||
const tailSize = 256 * 1024;
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`);
|
||||
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
const data = await res.json();
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
if (data.terminalBuffer) {
|
||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||
@@ -3464,6 +3614,7 @@ class CodemanApp {
|
||||
// very visible, causing the terminal to flash blank then repaint.
|
||||
const needsRewrite = data.terminalBuffer !== cachedBuffer;
|
||||
if (needsRewrite) {
|
||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
// Show truncation indicator if buffer was cut
|
||||
@@ -3472,7 +3623,7 @@ class CodemanApp {
|
||||
}
|
||||
// Use chunked write for large buffers to avoid UI jank
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
// Ensure terminal is scrolled to bottom after buffer load
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
@@ -3490,7 +3641,13 @@ class CodemanApp {
|
||||
this.terminal.reset();
|
||||
}
|
||||
|
||||
// Buffer load complete — drop the guard so user input clears state normally
|
||||
// Buffer load complete — unblock live SSE writes and flush any queued events.
|
||||
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped
|
||||
// the chunked write (small buffer, cache hit, or empty), we must call it here.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad();
|
||||
}
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
// Restore flushed offset and text for this session so the overlay positions
|
||||
@@ -3590,9 +3747,13 @@ class CodemanApp {
|
||||
}
|
||||
});
|
||||
|
||||
_crashDiag.log('FOCUS');
|
||||
this.terminal.focus();
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
} catch (err) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad();
|
||||
this._restoringFlushedState = false;
|
||||
console.error('Failed to load session terminal:', err);
|
||||
}
|
||||
|
||||
@@ -630,6 +630,30 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
});
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
|
||||
let _crashBreadcrumbs = '';
|
||||
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch {
|
||||
done(null, { data: body });
|
||||
}
|
||||
});
|
||||
this.app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch {
|
||||
done(null, { data: body });
|
||||
}
|
||||
});
|
||||
this.app.post('/api/crash-diag', (req, reply) => {
|
||||
_crashBreadcrumbs = String((req.body as { data?: string })?.data || '');
|
||||
reply.code(204).send();
|
||||
});
|
||||
this.app.get('/api/crash-diag', (_req, reply) => {
|
||||
reply.code(200).send({ breadcrumbs: _crashBreadcrumbs, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// Register all route modules
|
||||
const ctx = this.createRouteContext();
|
||||
registerPushRoutes(this.app, ctx);
|
||||
|
||||
Reference in New Issue
Block a user