diff --git a/CLAUDE.md b/CLAUDE.md index c6450831..1ff59435 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.1576 (must match `package.json` for npm publish) +**Version**: 0.1578 (must match `package.json` for npm publish) ## Project Overview diff --git a/docs/performance-investigation-report.md b/docs/performance-investigation-report.md new file mode 100644 index 00000000..74dc122c --- /dev/null +++ b/docs/performance-investigation-report.md @@ -0,0 +1,266 @@ +# Claudeman Performance Investigation Report + +**Date**: 2026-02-20 +**Scope**: Why Claudeman feels sluggish when multiple Claude tabs are very busy +**Method**: 4-agent parallel analysis of server, PTY pipeline, frontend, and background systems + +--- + +## Executive Summary + +When multiple Claude sessions are actively producing heavy terminal output (e.g., building, writing files, running tests), Claudeman's UI becomes sluggish. This investigation identified **14 bottlenecks** across 4 layers of the stack. The root cause is **cumulative event loop blocking** — no single operation is catastrophically slow, but dozens of small synchronous operations run on every PTY data chunk, and with N busy sessions producing chunks every few milliseconds, the event loop gets saturated. + +The most impactful findings are ranked by severity below. + +--- + +## Critical Findings (Event Loop Blockers) + +### 1. PTY Data Handler Chain — O(output_volume) per session, synchronous +**File**: `src/session.ts:986-1086` +**Severity**: CRITICAL + +Every chunk of PTY output from a busy Claude session runs through this synchronous chain on the Node.js event loop: + +``` +PTY onData → ANSI strip regex → ralph-tracker → bash-tool-parser → + token parser → CLI info parser → task description parser → + idle/working detection → emit('terminal') → emit('output') +``` + +**Key costs per chunk:** +- `ANSI_ESCAPE_PATTERN_FULL` regex (line 999): Complex regex with alternation, runs on every chunk where any consumer needs clean data +- `ralphTracker.processCleanData()` (line 1014): Splits into lines, runs regex per line, checks multi-line patterns +- `bashToolParser.processCleanData()` (line 1020): Similar line-by-line regex processing +- `parseTaskDescriptionsFromTerminalData()` (line 1038): Regex scan for parenthesized descriptions +- Working/idle detection (lines 1043-1085): Multiple `includes()` checks plus `getCleanData()` calls + +**The lazy `getCleanData()` pattern (line 997-1002)** was a good optimization — it avoids ANSI stripping when no consumer needs it. But when Ralph tracking is enabled (common during active work), `getCleanData()` is called on every chunk, negating the optimization. + +**With 5 busy sessions** producing 50+ chunks/second each, this means 250+ synchronous processing chains per second on the event loop. Each chain involves string allocation, regex matching, and line splitting. + +### 2. Broadcast Serialization — JSON.stringify on every flush +**File**: `src/web/server.ts:4941-4967` +**Severity**: CRITICAL + +The `broadcast()` method calls `JSON.stringify(data)` synchronously for every event. Terminal data is the highest-frequency event. During `flushTerminalBatches()` (line 5030), broadcast is called once per session with pending data. With 10 busy sessions flushing every 16-50ms, that's 200-625 `JSON.stringify` calls per second on terminal data alone. + +The terminal data payload is a string that gets double-encoded: the raw terminal string is embedded inside a JSON object `{id, data}`, then that object is JSON.stringify'd. For large chunks (up to 32KB per the `BATCH_FLUSH_THRESHOLD`), this creates significant garbage collection pressure. + +**Additionally**, the `session:updated` broadcast includes `toLightDetailedState()` which serializes `taskTree`, `tokens`, `bufferStats`, and `respawnConfig` — this is called on many state changes, not just terminal data. + +### 3. Single-Timer Batching — All sessions share one setTimeout +**File**: `src/web/server.ts:5017-5027` +**Severity**: HIGH + +The `batchTerminalData()` method uses a **single shared timer** (`this.terminalBatchTimer`) for all sessions. When the timer fires, `flushTerminalBatches()` iterates ALL pending sessions and broadcasts each one. This means: + +- One extremely busy session's rapid data can force the timer to fire at the minimum interval (16ms), flushing ALL sessions at that rate +- The flush itself iterates all pending sessions synchronously +- The `_minBatchInterval` optimization (line 5003) means the fastest session dictates the timer for everyone + +This creates a **thundering herd** effect: all session flushes happen in a single synchronous burst rather than being staggered. + +### 4. State Persistence Storms +**File**: `src/web/server.ts:3879-3917` +**Severity**: HIGH + +`persistSessionState()` is called from **28+ locations** in server.ts. Each call sets a 100ms debounce timer per session. During heavy activity, this means: + +- Frequent timer creation/cancellation (GC pressure) +- The actual persist (`_persistSessionStateNow`) calls `session.toState()` which creates a new object, then `store.setSession()` which triggers `JSON.stringify` of the entire state store and `writeFileSync` to disk + +The `StateStore` (via `state-store.ts`) debounces its own write, but the overhead is in the per-session `toState()` serialization and object creation, not just the disk write. + +--- + +## High-Severity Findings + +### 5. Ralph Tracker Line Processing — O(lines) per chunk +**File**: `src/ralph-tracker.ts:1337-1375` +**Severity**: HIGH (when Ralph tracking is enabled) + +When enabled, `processCleanData()`: +1. Appends to a line buffer (string concatenation) +2. Splits on `\n` (creates array) +3. Calls `processLine()` on each line (regex matching per line) +4. Calls `checkMultiLinePatterns()` (additional regex on full chunk) +5. Calls `maybeCleanupExpiredTodos()` (iterates todos Map) + +For a busy session producing 100+ lines/second, this is significant. The line buffer can grow up to `MAX_LINE_BUFFER_SIZE` before being truncated, and the split/iterate pattern creates garbage on every chunk. + +### 6. Subagent Watcher Polling — O(agents) every 1-10 seconds +**File**: `src/subagent-watcher.ts:225-274` +**Severity**: MEDIUM-HIGH + +Three periodic operations: +- **Poll interval** (1s): Lightweight check, but full directory scan every 5th poll (5s) +- **Liveness check** (10s): Runs `pgrep` (child process spawn), then iterates ALL tracked agents to check if alive. With 50+ subagents (common with agent teams), this is a non-trivial burst. +- **File watchers**: One `chokidar` watcher per tracked agent directory, plus transcript file watchers. With many agents, this means many active file watchers consuming kernel inotify resources. + +The `getClaudePids()` call spawns a child process (`pgrep`) every 10 seconds. Under heavy load, child process spawning competes with the event loop. + +### 7. SSE Client Iteration — O(clients) per broadcast +**File**: `src/web/server.ts:4964-4966` +**Severity**: MEDIUM + +Every `broadcast()` iterates all SSE clients to send the pre-formatted message. With multiple browser tabs or mobile clients, each flush sends data to every client. The `reply.raw.write()` call goes through Node's HTTP stream, which is generally non-blocking but can cause backpressure cascades. + +The backpressure handling (line 4916-4938) correctly skips backpressured clients, but the `once('drain')` handler sends a `session:needsRefresh` event, which the client responds to by fetching the full buffer — potentially a 2MB request — amplifying the problem. + +### 8. Event Emitter Fan-Out in Session +**File**: `src/session.ts:1008-1009` +**Severity**: MEDIUM + +Every PTY data chunk emits TWO events: `terminal` and `output`. The `terminal` event triggers `batchTerminalData()` in server.ts. The `output` event may trigger additional handlers. EventEmitter dispatch is synchronous — all listeners run before the next operation in the PTY handler continues. + +With busy sessions, this means every chunk blocks the event loop for: PTY processing + all terminal listeners + all output listeners. + +--- + +## Medium-Severity Findings + +### 9. Respawn Controller Timer Accumulation +**File**: `src/respawn-controller.ts` (various) +**Severity**: MEDIUM + +Each session with respawn enabled runs multiple timers: +- Idle detection timeout +- AI checker interval (when active) +- Output silence detection interval +- Token stability interval +- Circuit breaker state timeouts + +With 10 sessions with respawn, that's 50+ active timers. While individual timers are cheap, the cumulative effect on the event loop's timer queue is non-trivial — the libuv timer heap has O(log n) insertion but all callbacks run synchronously. + +### 10. Team Watcher Polling +**File**: `src/team-watcher.ts` +**Severity**: MEDIUM (when agent teams are active) + +Polls `~/.claude/teams/` directory every few seconds. Each poll reads config.json files and task files. With active teams, this adds filesystem reads to the event loop's I/O budget. + +### 11. Frontend Terminal Write Batching +**File**: `src/web/public/app.js` (batchTerminalWrite/flushPendingWrites) +**Severity**: MEDIUM + +The frontend batches terminal writes at `requestAnimationFrame` rate (16ms). When receiving SSE events from multiple busy sessions: +- `batchTerminalWrite()` is called for EVERY session's data, even sessions not currently displayed +- Terminal instances exist for all sessions (not just the active tab) +- Each `flushPendingWrites()` calls `terminal.write()` which triggers xterm.js rendering + +Hidden tabs still process terminal writes, consuming CPU for rendering that's never displayed. + +### 12. Frontend Connection Line Rendering +**File**: `src/web/public/app.js` (updateConnectionLines) +**Severity**: LOW-MEDIUM + +Connection lines between parent/child agent windows are recalculated on window moves, resizes, and potentially on terminal writes. With many subagent windows open, this involves DOM reads (getBoundingClientRect) that force layout recalculation. + +### 13. Image Watcher File System Events +**File**: `src/image-watcher.ts` +**Severity**: LOW + +Uses chokidar to watch for image files in session working directories. With many sessions in the same or overlapping directories, watchers may generate redundant events. The `awaitWriteFinish` and burst throttling mitigate this, but the kernel inotify resources add up. + +### 14. ANSI Escape Regex Complexity +**File**: `src/session.ts:999` +**Severity**: LOW (but cumulative) + +`ANSI_ESCAPE_PATTERN_FULL` is a complex regex with multiple alternation branches. While V8's regex engine handles this well for typical terminal data, adversarial input (deeply nested escape sequences) could cause superlinear matching time. The `FOCUS_ESCAPE_FILTER` regex runs first on every chunk. + +--- + +## Scaling Analysis + +| Resource | Per Session | 10 Sessions | 20 Sessions | +|----------|-------------|-------------|-------------| +| PTY data handlers | 1 synchronous chain | 10 chains competing for event loop | 20 chains — event loop saturation likely | +| Broadcast calls (terminal only) | 20-60/sec | 200-600/sec | 400-1200/sec | +| JSON.stringify (terminal) | 20-60/sec | 200-600/sec | 400-1200/sec | +| Active timers | ~5 | ~50 | ~100 | +| File watchers (subagents) | 2-5 | 20-50 | 40-100 | +| SSE writes per flush | N clients | N clients x 10 sessions | N clients x 20 sessions | +| Ralph line processing | O(lines/sec) | O(10 x lines/sec) | O(20 x lines/sec) | + +**The critical threshold appears to be 5-8 simultaneously busy sessions**, where the cumulative PTY processing + broadcast serialization + timer callbacks start to exceed the event loop's capacity for responsive handling. + +--- + +## Root Cause Architecture Diagram + +``` + Busy Claude Session 1 ─┐ + Busy Claude Session 2 ─┤ ┌──────────────────────┐ + Busy Claude Session 3 ─┼───→│ Node.js Event Loop │ + Busy Claude Session 4 ─┤ │ (SINGLE THREAD) │ + Busy Claude Session 5 ─┘ │ │ + │ PTY handlers (sync) │◄── BOTTLENECK 1 + │ ANSI strip regex │ + │ Ralph tracker │ + │ Bash tool parser │ + │ Idle detection │ + │ │ │ + │ ▼ │ + │ EventEmitter.emit() │◄── BOTTLENECK 2 + │ │ │ + │ ▼ │ + │ batchTerminalData() │ + │ (shared timer) │◄── BOTTLENECK 3 + │ │ │ + │ ▼ │ + │ flushTerminalBatches() │ + │ broadcast() per session│ + │ JSON.stringify() each │◄── BOTTLENECK 4 + │ write() to N clients │ + │ │ + │ + persistSessionState │◄── BOTTLENECK 5 + │ + respawn timers │ + │ + subagent polling │ + │ + team watcher │ + └────────────────────────┘ +``` + +--- + +## Recommendations (Not Implemented — For Discussion) + +### Tier 1: Highest Impact, Lowest Risk +1. **Disable processing for non-visible sessions**: Skip Ralph tracking, bash tool parsing, and task description parsing for sessions that no active SSE client is viewing. Only buffer terminal data. +2. **Per-session flush staggering**: Instead of one shared timer flushing all sessions, use individual timers offset by `index * (interval/N)` to spread flushes across the batch window. +3. **Skip hidden tab terminal writes on frontend**: Don't call `terminal.write()` for terminals not in the active tab. Lazy-load on tab switch. + +### Tier 2: Medium Impact +4. **Worker thread for ANSI stripping and parsing**: Move the regex-heavy ANSI strip + Ralph parsing to a worker thread pool. PTY data → worker → clean data back to main thread. +5. **Pre-formatted SSE messages for terminal data**: Since terminal events are just `{id, data}`, build the SSE message string directly without `JSON.stringify`. +6. **Adaptive processing based on load**: When event loop lag exceeds a threshold (measured via `setTimeout(0)` drift), reduce processing — skip Ralph, increase batch intervals, reduce subagent poll frequency. + +### Tier 3: Longer-Term Architectural +7. **Process-per-session or cluster mode**: Move each session's PTY handling to a separate Node.js worker or process, communicating to the main server via IPC. +8. **Binary protocol for terminal data**: Replace JSON-encoded SSE terminal events with binary frames (e.g., MessagePack or raw binary WebSocket frames) to eliminate double-encoding. +9. **Selective SSE subscriptions**: Clients subscribe to specific sessions instead of receiving all events. The server only broadcasts to interested clients. + +--- + +## How to Validate + +To confirm these findings, instrument with: +```typescript +// Add to event loop — measures how long synchronous work takes +let lastCheck = Date.now(); +setInterval(() => { + const now = Date.now(); + const lag = now - lastCheck - 100; // 100ms interval + if (lag > 10) console.log(`[PERF] Event loop lag: ${lag}ms`); + lastCheck = now; +}, 100); +``` + +And in `flushTerminalBatches()`: +```typescript +const start = performance.now(); +// ... existing flush logic ... +const elapsed = performance.now() - start; +if (elapsed > 5) console.log(`[PERF] Flush took ${elapsed.toFixed(1)}ms for ${this.terminalBatches.size} sessions`); +``` + +This will show exactly when and how much the event loop is being blocked during heavy session activity. diff --git a/package.json b/package.json index 30c64827..7a67a4ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1576", + "version": "0.1578", "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", diff --git a/src/web/public/app.js b/src/web/public/app.js index ed93b603..1e3a0c38 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -885,15 +885,12 @@ class LocalEchoOverlay { } screen.appendChild(this.overlay); this.pendingText = ''; - this._cursorPos = 0; // Cursor position within pendingText (0 = before first char) - this._mutationId = 0; // Increments on every content/cursor change, used for renderKey dedup this._storageKey = 'claudeman_local_echo_pending'; // Restore unsent input from previous session (survives reload/disconnect) try { const saved = localStorage.getItem(this._storageKey); if (saved) { this.pendingText = saved; - this._cursorPos = saved.length; this._render(); } } catch {} @@ -928,20 +925,14 @@ class LocalEchoOverlay { } addChar(char) { - // Insert at cursor position, not always at end - this.pendingText = this.pendingText.slice(0, this._cursorPos) + char + this.pendingText.slice(this._cursorPos); - this._cursorPos++; - this._mutationId++; + this.pendingText += char; this._persist(); this._render(); } removeChar() { - // Delete char before cursor position (like real backspace) - if (this._cursorPos > 0) { - this.pendingText = this.pendingText.slice(0, this._cursorPos - 1) + this.pendingText.slice(this._cursorPos); - this._cursorPos--; - this._mutationId++; + if (this.pendingText.length > 0) { + this.pendingText = this.pendingText.slice(0, -1); this._persist(); if (this.pendingText.length > 0) { this._render(); @@ -951,34 +942,15 @@ class LocalEchoOverlay { } } - moveCursorLeft() { - if (this._cursorPos > 0) { - this._cursorPos--; - this._mutationId++; - this._render(); - } - } - - moveCursorRight() { - if (this._cursorPos < this.pendingText.length) { - this._cursorPos++; - this._mutationId++; - this._render(); - } - } - appendText(text) { if (!text) return; - this.pendingText = this.pendingText.slice(0, this._cursorPos) + text + this.pendingText.slice(this._cursorPos); - this._cursorPos += text.length; - this._mutationId++; + this.pendingText += text; this._persist(); this._render(); } clear() { this.pendingText = ''; - this._cursorPos = 0; this._persist(); this._lastRenderKey = ''; this._lastPromptPos = null; @@ -1029,10 +1001,8 @@ class LocalEchoOverlay { const startCol = Math.min(activePrompt.col + 2, totalCols - 1); const firstLineCols = Math.max(1, totalCols - startCol); - // Skip redundant re-renders (e.g. from flushPendingWrites at 60fps). - // _mutationId increments on every content/cursor change; prompt position - // is included so moves from Ink redraws also trigger re-render. - const renderKey = `${this._mutationId}:${activePrompt.row}:${activePrompt.col}:${totalCols}`; + // Skip redundant re-renders (e.g. from flushPendingWrites at 60fps) + const renderKey = `${this.pendingText.length}:${activePrompt.row}:${activePrompt.col}:${totalCols}`; if (renderKey === this._lastRenderKey && this.overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -1066,24 +1036,15 @@ class LocalEchoOverlay { this.overlay.appendChild(lineEl); } - // Block cursor at cursor position (not always end of text) - // Map _cursorPos to visual line/col position - let cursorVisualPos = this._cursorPos; - let cursorLine = 0; - if (cursorVisualPos <= firstLineCols) { - cursorLine = 0; - } else { - cursorVisualPos -= firstLineCols; - cursorLine = 1 + Math.floor(cursorVisualPos / totalCols); - cursorVisualPos = cursorVisualPos % totalCols; - } - const cursorLineLeft = cursorLine === 0 ? startCol : 0; - const cursorCol = cursorLineLeft + cursorVisualPos; + // Block cursor at end of last line + const lastLine = lines[lines.length - 1]; + const lastLineLeft = lines.length === 1 ? startCol : 0; + const cursorCol = lastLineLeft + lastLine.length; if (cursorCol < totalCols) { const cursor = document.createElement('span'); cursor.style.cssText = 'position:absolute;display:inline-block'; cursor.style.left = (cursorCol * cellW) + 'px'; - cursor.style.top = (cursorLine * cellH) + 'px'; + cursor.style.top = ((lines.length - 1) * cellH) + 'px'; cursor.style.width = cellW + 'px'; cursor.style.height = cellH + 'px'; cursor.style.backgroundColor = this.terminal.options?.theme?.cursor || '#e0e0e0'; @@ -1728,13 +1689,8 @@ class ClaudemanApp { this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session this._connectionStatus = 'connected'; - // Serialized input sender — at most one fetch in-flight at a time. - // New input arriving while a send is in-flight merges into a single - // pending buffer (never a growing queue). Guarantees ordering without - // blocking the UI — typing and overlay rendering remain fully instant. - this._inputSendActive = false; // true while a fetch is in-flight - this._inputSendPending = ''; // chars waiting behind the in-flight request - this._inputSendPendingSession = null; + // Sequential input send chain — ensures keystroke ordering across async fetches + this._inputSendChain = Promise.resolve(); // Local echo overlay — DOM overlay positioned at the visible ❯ prompt // (not at buffer.cursorY, which reflects Ink's internal cursor position) @@ -2128,49 +2084,29 @@ class ClaudemanApp { // (50ms debounce) so Tab completion and tab-switching work. if (this._localEchoEnabled) { if (data === '\x7f') { - // Backspace: delete char at cursor position, send \x7f to PTY in background + // Backspace: remove last char from overlay, send \x7f to PTY in background this._localEchoOverlay?.removeChar(); this._localEchoBgBuffer += '\x7f'; scheduleBgFlush(); return; } if (/^[\r\n]+$/.test(data)) { - // Enter: use the overlay text as the authoritative source of truth. - // Background sends may have dropped or reordered chars (transient - // network errors, HTTP/1.1 connection races). Instead of trusting - // the PTY state, we: (1) wait for in-flight sends to complete, - // (2) clear whatever the PTY has with backspaces, (3) re-send the - // correct text from the overlay, (4) send Enter. All in one batch - // so ordering is guaranteed. - const enterSessionId = this.activeSessionId; - const correctText = this._localEchoOverlay?.pendingText || ''; + // Enter: drain any background-buffered chars, then send \r after 120ms. + // PTY already has the text from background sends; drain catches any remainder. this._localEchoOverlay?.clear(); - // Cancel all pending timers and buffers — we'll re-send everything if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } - if (this._localEchoBgTimer) { - clearTimeout(this._localEchoBgTimer); - this._localEchoBgTimer = null; + const remainder = drainBgBuffer(); + if (remainder) { + this._pendingInput += remainder; + flushInput(); } - this._localEchoBgBuffer = ''; - this._pendingInput = ''; - // Discard any unsent pending chars — they'll be re-sent correctly - this._inputSendPending = ''; - // Wait for any in-flight request to finish, then send correction - (async () => { - await this._waitForSendIdle(); - if (!enterSessionId) return; - if (correctText) { - // Clear PTY input (backspaces) + re-type correct text + Enter. - // Extra backspaces on empty Ink input are harmless (no-ops). - const bs = '\x7f'.repeat(correctText.length + 10); - this._sendInputAsync(enterSessionId, bs + correctText + '\r'); - } else { - this._sendInputAsync(enterSessionId, '\r'); - } - })(); + setTimeout(() => { + this._pendingInput += '\r'; + flushInput(); + }, 120); return; } if (data.length > 1 && data.charCodeAt(0) >= 32) { @@ -2186,21 +2122,8 @@ class ClaudemanApp { flushInput(); return; } - // Arrow left/right: move cursor within overlay text, send to PTY in background - if (data === '\x1b[D') { - this._localEchoOverlay?.moveCursorLeft(); - this._localEchoBgBuffer += data; - scheduleBgFlush(); - return; - } - if (data === '\x1b[C') { - this._localEchoOverlay?.moveCursorRight(); - this._localEchoBgBuffer += data; - scheduleBgFlush(); - return; - } if (data.charCodeAt(0) < 32) { - // Other control chars (Ctrl+C, up/down arrows, etc.): clear overlay, drain bg, send immediately + // Control chars (Ctrl+C, escape sequences): clear overlay, drain bg, send immediately this._localEchoOverlay?.clear(); const remainder = drainBgBuffer(); if (remainder) { @@ -2215,7 +2138,7 @@ class ClaudemanApp { return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { - // Printable char: insert at cursor position + queue for background send to PTY + // Printable char: add to overlay + queue for background send to PTY this._localEchoOverlay?.addChar(data); this._localEchoBgBuffer += data; scheduleBgFlush(); @@ -3796,61 +3719,35 @@ class ClaudemanApp { return; } - // Merge into the pending buffer. If a request is already in-flight, - // this just accumulates — the send loop will pick it up when the - // current request completes. No growing queue, no blocked typing. - this._inputSendPending += input; - this._inputSendPendingSession = sessionId; + // Chain on dispatch only — wait for the previous request to be sent before + // dispatching the next one (preserves keystroke ordering), but don't wait + // for the server's response. The server handles writeViaMux as + // fire-and-forget anyway, so the HTTP response carries no useful data + // beyond success/failure for retry purposes. + this._inputSendChain = this._inputSendChain.then(() => { + const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input }), + keepalive: input.length < 65536, + }); - // If already sending, the loop will drain the pending buffer — done. - if (this._inputSendActive) return; - - // Start the send loop. Runs entirely in the background; callers - // (onData, bgBuffer timer, Enter handler) return immediately. - this._inputSendActive = true; - (async () => { - while (this._inputSendPending) { - const batch = this._inputSendPending; - const batchSession = this._inputSendPendingSession; - this._inputSendPending = ''; - try { - const resp = await fetch(`/api/sessions/${batchSession}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: batch }), - keepalive: batch.length < 65536, - }); - if (!resp.ok) { - this._enqueueInput(batchSession, batch); - } else { - this.clearPendingHooks(batchSession); - } - } catch { - this._enqueueInput(batchSession, batch); + // Handle response asynchronously — don't block next keystroke on response + fetchPromise.then(resp => { + if (!resp.ok) { + this._enqueueInput(sessionId, input); + } else { + this.clearPendingHooks(sessionId); } - } - this._inputSendActive = false; - })(); - } + }).catch(() => { + this._enqueueInput(sessionId, input); + }); - - /** - * Wait for the background send loop to finish its current in-flight request. - * Used by the Enter handler to ensure all background-sent chars have been - * delivered before sending the authoritative correction. - */ - _waitForSendIdle(timeoutMs = 500) { - if (!this._inputSendActive) return Promise.resolve(); - return new Promise(resolve => { - const deadline = Date.now() + timeoutMs; - const check = () => { - if (!this._inputSendActive || Date.now() >= deadline) resolve(); - else setTimeout(check, 10); - }; - check(); + // Return immediately after fetch is dispatched (don't await response) }); } + _enqueueInput(sessionId, input) { const existing = this._inputQueue.get(sessionId) || ''; let combined = existing + input; @@ -4153,7 +4050,6 @@ class ClaudemanApp { // selectSession loads the terminal buffer, so we schedule rerender after it settles. if (savedEchoText && this._localEchoOverlay) { this._localEchoOverlay.pendingText = savedEchoText; - this._localEchoOverlay._cursorPos = savedEchoText.length; this._localEchoOverlay._persist(); // Delay rerender until buffer load completes and prompt is visible setTimeout(() => this._localEchoOverlay?.rerender(), 500); @@ -4822,7 +4718,6 @@ class ClaudemanApp { const savedEcho = this.localEchoTextCache.get(sessionId); if (savedEcho && this._localEchoOverlay) { this._localEchoOverlay.pendingText = savedEcho; - this._localEchoOverlay._cursorPos = savedEcho.length; this._localEchoOverlay._persist(); for (const delay of [150, 500, 1000]) { setTimeout(() => { diff --git a/src/web/public/index.html b/src/web/public/index.html index 10820f75..eb885753 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -8,8 +8,8 @@