diff --git a/CLAUDE.md b/CLAUDE.md index 99a4f9de..d910fd0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1579 (must match `package.json` for npm publish) +**Version**: 0.1580 (must match `package.json` for npm publish) ## Project Overview diff --git a/package-lock.json b/package-lock.json index 358fd103..81913002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claudeman", - "version": "0.1536", + "version": "0.1579", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claudeman", - "version": "0.1536", + "version": "0.1579", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -20,6 +20,7 @@ "uuid": "^10.0.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", + "xterm-addon-webgl": "^0.16.0", "zod": "^4.3.6" }, "bin": { @@ -4862,6 +4863,16 @@ "xterm": "^5.0.0" } }, + "node_modules/xterm-addon-webgl": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz", + "integrity": "sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-webgl instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 0b4f00f2..33769797 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1579", + "version": "0.1580", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", @@ -10,7 +10,7 @@ }, "scripts": { "postinstall": "node scripts/postinstall.js", - "build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates dist/web/public/vendor && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/ && cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ && npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js && npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js && npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite && npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite && npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite", + "build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates dist/web/public/vendor && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/ && cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ && npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js && npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js && cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js && npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite && npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite && npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite && for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do [ -f \"$f\" ] && gzip -9 -k \"$f\" && brotli -9 -k \"$f\"; done", "start": "node dist/index.js", "dev": "tsx src/index.ts", "web": "node dist/index.js web", @@ -47,6 +47,7 @@ "uuid": "^10.0.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", + "xterm-addon-webgl": "^0.16.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/session.ts b/src/session.ts index c42219d0..862f75cc 100644 --- a/src/session.ts +++ b/src/session.ts @@ -467,6 +467,10 @@ export class Session extends EventEmitter { return this._terminalBuffer.value; } + get terminalBufferLength(): number { + return this._terminalBuffer.length; + } + get textOutput(): string { return this._textOutput.value; } diff --git a/src/web/public/app.js b/src/web/public/app.js index 1e3a0c38..a679ff94 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -33,6 +33,15 @@ const WINDOW_MIN_WIDTH_PX = 200; const WINDOW_MIN_HEIGHT_PX = 200; const WINDOW_DEFAULT_WIDTH_PX = 300; +// Scheduler API — prioritize terminal writes over background UI updates. +// scheduler.postTask('background') defers non-critical work (connection lines, panel renders) +// so the main thread stays free for terminal rendering at 60fps. +const _hasScheduler = typeof globalThis.scheduler?.postTask === 'function'; +function scheduleBackground(fn) { + if (_hasScheduler) { scheduler.postTask(fn, { priority: 'background' }); } + else { requestAnimationFrame(fn); } +} + // DEC mode 2026 - Synchronized Output // Wrap terminal writes with these markers to prevent partial-frame flicker. // Terminal buffers all output between markers and renders atomically. @@ -1871,6 +1880,16 @@ class ClaudemanApp { const container = document.getElementById('terminalContainer'); this.terminal.open(container); + + // Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure) + if (typeof WebglAddon !== 'undefined') { + try { + const webglAddon = new WebglAddon.WebglAddon(); + webglAddon.onContextLoss(() => { webglAddon.dispose(); }); + this.terminal.loadAddon(webglAddon); + } catch (_e) { /* WebGL2 unavailable — canvas renderer used */ } + } + this._localEchoOverlay = new LocalEchoOverlay(this.terminal); // On mobile Safari, delay initial fit() to allow layout to settle @@ -3624,6 +3643,16 @@ class ClaudemanApp { this.subagentToolResults.delete(data.agentId); } }, 5 * 60 * 1000); // 5 minutes + + // Prune stale completed agents from main maps after 30 minutes + // Keeps subagents/subagentParentMap from growing unbounded in 24h sessions + setTimeout(() => { + const agent = this.subagents.get(data.agentId); + if (agent?.status === 'completed' && !this.subagentWindows.has(data.agentId)) { + this.subagents.delete(data.agentId); + this.subagentParentMap.delete(data.agentId); + } + }, 30 * 60 * 1000); // 30 minutes }); // ========== Image Detection Events (Screenshots & Generated Images) ========== @@ -12399,7 +12428,7 @@ class ClaudemanApp { clearTimeout(this._subagentPanelRenderTimeout); } this._subagentPanelRenderTimeout = setTimeout(() => { - this._renderSubagentPanelImmediate(); + scheduleBackground(() => this._renderSubagentPanelImmediate()); }, 150); } @@ -12902,10 +12931,11 @@ class ClaudemanApp { // This map stores agentId -> sessionId, where sessionId is the tab's data-id. updateConnectionLines() { - // Coalesce multiple calls within the same frame into a single rAF + // Coalesce multiple calls — uses background scheduler priority to avoid + // competing with terminal writes for main thread time if (!this._connectionLinesScheduled) { this._connectionLinesScheduled = true; - requestAnimationFrame(() => { + scheduleBackground(() => { this._connectionLinesScheduled = false; this._updateConnectionLinesImmediate(); }); diff --git a/src/web/public/index.html b/src/web/public/index.html index eb885753..27b8793f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -17,6 +17,7 @@ 'defer' preserves execution order (xterm loads before fit addon). --> + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index d273ad05..dc916b25 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2990,6 +2990,8 @@ body { border-radius: 6px; border-left: 2px solid var(--border); transition: background 0.15s; + content-visibility: auto; + contain-intrinsic-size: auto 40px; } .timeline-event:hover { @@ -3874,6 +3876,8 @@ kbd { cursor: pointer; transition: background 0.15s; animation: notif-slide-in 0.2s ease; + content-visibility: auto; + contain-intrinsic-size: auto 60px; } .notif-item:hover { @@ -4631,6 +4635,8 @@ kbd { margin-bottom: 0.4rem; cursor: pointer; transition: background 0.15s; + content-visibility: auto; + contain-intrinsic-size: auto 48px; } .subagent-item:hover { @@ -5731,6 +5737,8 @@ kbd { .log-viewer-window-body .log-line { padding: 0.1rem 0; + content-visibility: auto; + contain-intrinsic-size: auto 20px; } .log-viewer-window-body .log-error { diff --git a/src/web/server.ts b/src/web/server.ts index 04e687ff..c4c61533 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -632,11 +632,13 @@ export class WebServer extends EventEmitter { }); // Serve static files — versioned assets (?v=X) are immutable, cache aggressively + // preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), prefix: '/', maxAge: '1y', immutable: true, + preCompressed: true, }); // SSE endpoint for real-time updates @@ -1756,32 +1758,15 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - // Clean the buffer: remove junk before actual Claude content - let cleanBuffer = session.terminalBuffer; - - // Find where Claude banner starts (has color codes before "Claude") - const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN); - if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) { - // Find the start of that line - let lineStart = claudeMatch.index; - while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') { - lineStart--; - } - cleanBuffer = cleanBuffer.slice(lineStart); - } - - // Remove Ctrl+L and leading whitespace - cleanBuffer = cleanBuffer - .replace(CTRL_L_PATTERN, '') - .replace(LEADING_WHITESPACE_PATTERN, ''); - - // Optionally truncate to last N bytes for faster initial load const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; - const fullSize = cleanBuffer.length; + const fullSize = session.terminalBufferLength; let truncated = false; + let cleanBuffer: string; - if (tailBytes > 0 && cleanBuffer.length > tailBytes) { - cleanBuffer = cleanBuffer.slice(-tailBytes); + if (tailBytes > 0 && fullSize > tailBytes) { + // Fast path: tail from the end, skip expensive banner search on full 2MB buffer. + // Banner is near the top and gets discarded by tail anyway. + cleanBuffer = session.terminalBuffer.slice(-tailBytes); truncated = true; // Avoid starting mid-ANSI-escape: find first newline within the first 4KB // and start from there. This prevents xterm.js from parsing a partial escape @@ -1790,8 +1775,26 @@ export class WebServer extends EventEmitter { if (firstNewline > 0 && firstNewline < 4096) { cleanBuffer = cleanBuffer.slice(firstNewline + 1); } + } else { + // Full buffer: clean junk before actual Claude content + cleanBuffer = session.terminalBuffer; + + // Find where Claude banner starts (has color codes before "Claude") + const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN); + if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) { + let lineStart = claudeMatch.index; + while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') { + lineStart--; + } + cleanBuffer = cleanBuffer.slice(lineStart); + } } + // Remove Ctrl+L and leading whitespace (cheap on tailed subset) + cleanBuffer = cleanBuffer + .replace(CTRL_L_PATTERN, '') + .replace(LEADING_WHITESPACE_PATTERN, ''); + return { terminalBuffer: cleanBuffer, status: session.status,