mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
chore: bump version to 0.1578
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`)
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+49
-154
@@ -885,15 +885,12 @@ class LocalEchoOverlay {
|
|||||||
}
|
}
|
||||||
screen.appendChild(this.overlay);
|
screen.appendChild(this.overlay);
|
||||||
this.pendingText = '';
|
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';
|
this._storageKey = 'claudeman_local_echo_pending';
|
||||||
// Restore unsent input from previous session (survives reload/disconnect)
|
// Restore unsent input from previous session (survives reload/disconnect)
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(this._storageKey);
|
const saved = localStorage.getItem(this._storageKey);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.pendingText = saved;
|
this.pendingText = saved;
|
||||||
this._cursorPos = saved.length;
|
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -928,20 +925,14 @@ class LocalEchoOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addChar(char) {
|
addChar(char) {
|
||||||
// Insert at cursor position, not always at end
|
this.pendingText += char;
|
||||||
this.pendingText = this.pendingText.slice(0, this._cursorPos) + char + this.pendingText.slice(this._cursorPos);
|
|
||||||
this._cursorPos++;
|
|
||||||
this._mutationId++;
|
|
||||||
this._persist();
|
this._persist();
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeChar() {
|
removeChar() {
|
||||||
// Delete char before cursor position (like real backspace)
|
if (this.pendingText.length > 0) {
|
||||||
if (this._cursorPos > 0) {
|
this.pendingText = this.pendingText.slice(0, -1);
|
||||||
this.pendingText = this.pendingText.slice(0, this._cursorPos - 1) + this.pendingText.slice(this._cursorPos);
|
|
||||||
this._cursorPos--;
|
|
||||||
this._mutationId++;
|
|
||||||
this._persist();
|
this._persist();
|
||||||
if (this.pendingText.length > 0) {
|
if (this.pendingText.length > 0) {
|
||||||
this._render();
|
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) {
|
appendText(text) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
this.pendingText = this.pendingText.slice(0, this._cursorPos) + text + this.pendingText.slice(this._cursorPos);
|
this.pendingText += text;
|
||||||
this._cursorPos += text.length;
|
|
||||||
this._mutationId++;
|
|
||||||
this._persist();
|
this._persist();
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.pendingText = '';
|
this.pendingText = '';
|
||||||
this._cursorPos = 0;
|
|
||||||
this._persist();
|
this._persist();
|
||||||
this._lastRenderKey = '';
|
this._lastRenderKey = '';
|
||||||
this._lastPromptPos = null;
|
this._lastPromptPos = null;
|
||||||
@@ -1029,10 +1001,8 @@ class LocalEchoOverlay {
|
|||||||
const startCol = Math.min(activePrompt.col + 2, totalCols - 1);
|
const startCol = Math.min(activePrompt.col + 2, totalCols - 1);
|
||||||
const firstLineCols = Math.max(1, totalCols - startCol);
|
const firstLineCols = Math.max(1, totalCols - startCol);
|
||||||
|
|
||||||
// Skip redundant re-renders (e.g. from flushPendingWrites at 60fps).
|
// Skip redundant re-renders (e.g. from flushPendingWrites at 60fps)
|
||||||
// _mutationId increments on every content/cursor change; prompt position
|
const renderKey = `${this.pendingText.length}:${activePrompt.row}:${activePrompt.col}:${totalCols}`;
|
||||||
// is included so moves from Ink redraws also trigger re-render.
|
|
||||||
const renderKey = `${this._mutationId}:${activePrompt.row}:${activePrompt.col}:${totalCols}`;
|
|
||||||
if (renderKey === this._lastRenderKey && this.overlay.style.display !== 'none') return;
|
if (renderKey === this._lastRenderKey && this.overlay.style.display !== 'none') return;
|
||||||
this._lastRenderKey = renderKey;
|
this._lastRenderKey = renderKey;
|
||||||
|
|
||||||
@@ -1066,24 +1036,15 @@ class LocalEchoOverlay {
|
|||||||
this.overlay.appendChild(lineEl);
|
this.overlay.appendChild(lineEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block cursor at cursor position (not always end of text)
|
// Block cursor at end of last line
|
||||||
// Map _cursorPos to visual line/col position
|
const lastLine = lines[lines.length - 1];
|
||||||
let cursorVisualPos = this._cursorPos;
|
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
||||||
let cursorLine = 0;
|
const cursorCol = lastLineLeft + lastLine.length;
|
||||||
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;
|
|
||||||
if (cursorCol < totalCols) {
|
if (cursorCol < totalCols) {
|
||||||
const cursor = document.createElement('span');
|
const cursor = document.createElement('span');
|
||||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||||
cursor.style.left = (cursorCol * cellW) + 'px';
|
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.width = cellW + 'px';
|
||||||
cursor.style.height = cellH + 'px';
|
cursor.style.height = cellH + 'px';
|
||||||
cursor.style.backgroundColor = this.terminal.options?.theme?.cursor || '#e0e0e0';
|
cursor.style.backgroundColor = this.terminal.options?.theme?.cursor || '#e0e0e0';
|
||||||
@@ -1728,13 +1689,8 @@ class ClaudemanApp {
|
|||||||
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
|
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
|
||||||
this._connectionStatus = 'connected';
|
this._connectionStatus = 'connected';
|
||||||
|
|
||||||
// Serialized input sender — at most one fetch in-flight at a time.
|
// Sequential input send chain — ensures keystroke ordering across async fetches
|
||||||
// New input arriving while a send is in-flight merges into a single
|
this._inputSendChain = Promise.resolve();
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
||||||
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
// (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.
|
// (50ms debounce) so Tab completion and tab-switching work.
|
||||||
if (this._localEchoEnabled) {
|
if (this._localEchoEnabled) {
|
||||||
if (data === '\x7f') {
|
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._localEchoOverlay?.removeChar();
|
||||||
this._localEchoBgBuffer += '\x7f';
|
this._localEchoBgBuffer += '\x7f';
|
||||||
scheduleBgFlush();
|
scheduleBgFlush();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/^[\r\n]+$/.test(data)) {
|
if (/^[\r\n]+$/.test(data)) {
|
||||||
// Enter: use the overlay text as the authoritative source of truth.
|
// Enter: drain any background-buffered chars, then send \r after 120ms.
|
||||||
// Background sends may have dropped or reordered chars (transient
|
// PTY already has the text from background sends; drain catches any remainder.
|
||||||
// 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 || '';
|
|
||||||
this._localEchoOverlay?.clear();
|
this._localEchoOverlay?.clear();
|
||||||
// Cancel all pending timers and buffers — we'll re-send everything
|
|
||||||
if (this._inputFlushTimeout) {
|
if (this._inputFlushTimeout) {
|
||||||
clearTimeout(this._inputFlushTimeout);
|
clearTimeout(this._inputFlushTimeout);
|
||||||
this._inputFlushTimeout = null;
|
this._inputFlushTimeout = null;
|
||||||
}
|
}
|
||||||
if (this._localEchoBgTimer) {
|
const remainder = drainBgBuffer();
|
||||||
clearTimeout(this._localEchoBgTimer);
|
if (remainder) {
|
||||||
this._localEchoBgTimer = null;
|
this._pendingInput += remainder;
|
||||||
|
flushInput();
|
||||||
}
|
}
|
||||||
this._localEchoBgBuffer = '';
|
setTimeout(() => {
|
||||||
this._pendingInput = '';
|
this._pendingInput += '\r';
|
||||||
// Discard any unsent pending chars — they'll be re-sent correctly
|
flushInput();
|
||||||
this._inputSendPending = '';
|
}, 120);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.length > 1 && data.charCodeAt(0) >= 32) {
|
if (data.length > 1 && data.charCodeAt(0) >= 32) {
|
||||||
@@ -2186,21 +2122,8 @@ class ClaudemanApp {
|
|||||||
flushInput();
|
flushInput();
|
||||||
return;
|
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) {
|
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();
|
this._localEchoOverlay?.clear();
|
||||||
const remainder = drainBgBuffer();
|
const remainder = drainBgBuffer();
|
||||||
if (remainder) {
|
if (remainder) {
|
||||||
@@ -2215,7 +2138,7 @@ class ClaudemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
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._localEchoOverlay?.addChar(data);
|
||||||
this._localEchoBgBuffer += data;
|
this._localEchoBgBuffer += data;
|
||||||
scheduleBgFlush();
|
scheduleBgFlush();
|
||||||
@@ -3796,61 +3719,35 @@ class ClaudemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge into the pending buffer. If a request is already in-flight,
|
// Chain on dispatch only — wait for the previous request to be sent before
|
||||||
// this just accumulates — the send loop will pick it up when the
|
// dispatching the next one (preserves keystroke ordering), but don't wait
|
||||||
// current request completes. No growing queue, no blocked typing.
|
// for the server's response. The server handles writeViaMux as
|
||||||
this._inputSendPending += input;
|
// fire-and-forget anyway, so the HTTP response carries no useful data
|
||||||
this._inputSendPendingSession = sessionId;
|
// 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.
|
// Handle response asynchronously — don't block next keystroke on response
|
||||||
if (this._inputSendActive) return;
|
fetchPromise.then(resp => {
|
||||||
|
if (!resp.ok) {
|
||||||
// Start the send loop. Runs entirely in the background; callers
|
this._enqueueInput(sessionId, input);
|
||||||
// (onData, bgBuffer timer, Enter handler) return immediately.
|
} else {
|
||||||
this._inputSendActive = true;
|
this.clearPendingHooks(sessionId);
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
}
|
}).catch(() => {
|
||||||
this._inputSendActive = false;
|
this._enqueueInput(sessionId, input);
|
||||||
})();
|
});
|
||||||
}
|
|
||||||
|
|
||||||
|
// Return immediately after fetch is dispatched (don't await response)
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_enqueueInput(sessionId, input) {
|
_enqueueInput(sessionId, input) {
|
||||||
const existing = this._inputQueue.get(sessionId) || '';
|
const existing = this._inputQueue.get(sessionId) || '';
|
||||||
let combined = existing + input;
|
let combined = existing + input;
|
||||||
@@ -4153,7 +4050,6 @@ class ClaudemanApp {
|
|||||||
// selectSession loads the terminal buffer, so we schedule rerender after it settles.
|
// selectSession loads the terminal buffer, so we schedule rerender after it settles.
|
||||||
if (savedEchoText && this._localEchoOverlay) {
|
if (savedEchoText && this._localEchoOverlay) {
|
||||||
this._localEchoOverlay.pendingText = savedEchoText;
|
this._localEchoOverlay.pendingText = savedEchoText;
|
||||||
this._localEchoOverlay._cursorPos = savedEchoText.length;
|
|
||||||
this._localEchoOverlay._persist();
|
this._localEchoOverlay._persist();
|
||||||
// Delay rerender until buffer load completes and prompt is visible
|
// Delay rerender until buffer load completes and prompt is visible
|
||||||
setTimeout(() => this._localEchoOverlay?.rerender(), 500);
|
setTimeout(() => this._localEchoOverlay?.rerender(), 500);
|
||||||
@@ -4822,7 +4718,6 @@ class ClaudemanApp {
|
|||||||
const savedEcho = this.localEchoTextCache.get(sessionId);
|
const savedEcho = this.localEchoTextCache.get(sessionId);
|
||||||
if (savedEcho && this._localEchoOverlay) {
|
if (savedEcho && this._localEchoOverlay) {
|
||||||
this._localEchoOverlay.pendingText = savedEcho;
|
this._localEchoOverlay.pendingText = savedEcho;
|
||||||
this._localEchoOverlay._cursorPos = savedEcho.length;
|
|
||||||
this._localEchoOverlay._persist();
|
this._localEchoOverlay._persist();
|
||||||
for (const delay of [150, 500, 1000]) {
|
for (const delay of [150, 500, 1000]) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<meta name="google" content="notranslate">
|
<meta name="google" content="notranslate">
|
||||||
<title>Claudeman</title>
|
<title>Claudeman</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="styles.css?v=0.1556">
|
<link rel="stylesheet" href="styles.css?v=0.1578">
|
||||||
<link rel="stylesheet" href="mobile.css?v=0.1556" media="(max-width: 1023px)">
|
<link rel="stylesheet" href="mobile.css?v=0.1578" media="(max-width: 1023px)">
|
||||||
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
|
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
|
||||||
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
||||||
<noscript><link rel="stylesheet" href="vendor/xterm.css"></noscript>
|
<noscript><link rel="stylesheet" href="vendor/xterm.css"></noscript>
|
||||||
@@ -1558,6 +1558,6 @@
|
|||||||
<!-- Lines drawn dynamically -->
|
<!-- Lines drawn dynamically -->
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<script defer src="app.js?v=0.1556"></script>
|
<script defer src="app.js?v=0.1578"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user