mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5814947d6 | ||
|
|
b620511d0e | ||
|
|
525f02f502 | ||
|
|
e07c59477d | ||
|
|
2b8a522cbd | ||
|
|
4abe055182 |
@@ -1,5 +1,17 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Re-enable WebGL renderer with 48KB/frame flush cap protection against GPU stalls
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.4 (must match `package.json`)
|
||||
**Version**: 0.3.6 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.6",
|
||||
"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');
|
||||
|
||||
|
||||
+175
-20
@@ -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,22 @@ 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 for GPU-accelerated terminal rendering.
|
||||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||||
// oversized terminal.write() calls that triggered the stalls.
|
||||
// Disable with ?nowebgl URL param if GPU issues return.
|
||||
this._webglAddon = null;
|
||||
if (typeof WebglAddon !== 'undefined') {
|
||||
if (!new URLSearchParams(location.search).has('nowebgl') && 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 */ }
|
||||
}
|
||||
|
||||
@@ -1286,24 +1370,60 @@ 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
|
||||
// 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).
|
||||
for (const segment of segments) {
|
||||
if (segment) {
|
||||
const content = segment.startsWith(DEC_SYNC_START)
|
||||
? segment.slice(DEC_SYNC_START.length)
|
||||
: segment;
|
||||
if (content) this.terminal.write(content);
|
||||
const segments = extractSyncSegments(joined);
|
||||
|
||||
// Write segments respecting a per-frame byte budget.
|
||||
// Each DEC 2026 sync segment is a complete Ink redraw — writing whole segments
|
||||
// preserves atomicity (no flicker). But when total data exceeds 48KB, defer
|
||||
// remaining segments to the next frame to prevent terminal.write() from blocking
|
||||
// the main thread. 141KB single-frame writes have been observed to freeze Chrome
|
||||
// for 2+ minutes even with the canvas renderer.
|
||||
const MAX_FRAME_BYTES = 49152; // 48KB budget per frame
|
||||
let bytesThisFrame = 0;
|
||||
let deferred = false;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
if (!segment) continue;
|
||||
const content = segment.startsWith(DEC_SYNC_START)
|
||||
? segment.slice(DEC_SYNC_START.length)
|
||||
: segment;
|
||||
if (!content) continue;
|
||||
|
||||
// If we'd exceed the budget, defer this and all remaining segments
|
||||
if (bytesThisFrame > 0 && bytesThisFrame + content.length > MAX_FRAME_BYTES) {
|
||||
// Re-queue remaining segments as raw content for next flush
|
||||
const remaining = segments.slice(i).map(s => {
|
||||
if (!s) return '';
|
||||
return s.startsWith(DEC_SYNC_START) ? s.slice(DEC_SYNC_START.length) : s;
|
||||
}).filter(Boolean).join('');
|
||||
if (remaining) {
|
||||
this.pendingWrites.push(remaining);
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
deferred = true;
|
||||
break;
|
||||
}
|
||||
|
||||
this.terminal.write(content);
|
||||
bytesThisFrame += content.length;
|
||||
}
|
||||
const _dt = performance.now() - _t0;
|
||||
if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`);
|
||||
|
||||
// Sticky scroll: if user was at bottom, keep them there after new output
|
||||
if (this._wasAtBottomBeforeWrite) {
|
||||
@@ -1396,15 +1516,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
|
||||
@@ -1627,6 +1755,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);
|
||||
}
|
||||
|
||||
@@ -1685,6 +1814,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);
|
||||
}
|
||||
}
|
||||
@@ -3346,6 +3476,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;
|
||||
|
||||
@@ -3474,23 +3608,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.
|
||||
@@ -3498,6 +3642,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
|
||||
@@ -3506,7 +3651,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();
|
||||
}
|
||||
@@ -3524,7 +3669,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
|
||||
@@ -3624,9 +3775,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