chore: bump version to 0.1503

This commit is contained in:
arkon
2026-02-13 21:07:49 +01:00
parent d63fe2063f
commit 26373aabd0
6 changed files with 193 additions and 83 deletions
+1 -1
View File
@@ -25,7 +25,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.1502 (must match `package.json` for npm publish) **Version**: 0.1503 (must match `package.json` for npm publish)
## Project Overview ## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "claudeman", "name": "claudeman",
"version": "0.1502", "version": "0.1503",
"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",
+13 -2
View File
@@ -754,14 +754,25 @@ export class Session extends EventEmitter {
} }
toDetailedState() { toDetailedState() {
return {
...this.toLightDetailedState(),
textOutput: this._textOutput.value,
terminalBuffer: this._terminalBuffer.value,
};
}
/**
* Lightweight detailed state that excludes heavy buffers (textOutput, terminalBuffer).
* Use for SSE session:updated broadcasts where buffers aren't needed.
* Full buffers are fetched on-demand via /api/sessions/:id/terminal.
*/
toLightDetailedState() {
return { return {
...this.toState(), ...this.toState(),
name: this._name, name: this._name,
mode: this.mode, mode: this.mode,
claudeSessionId: this._claudeSessionId, claudeSessionId: this._claudeSessionId,
totalCost: this._totalCost, totalCost: this._totalCost,
textOutput: this._textOutput.value,
terminalBuffer: this._terminalBuffer.value,
messageCount: this._messages.length, messageCount: this._messages.length,
isWorking: this._isWorking, isWorking: this._isWorking,
lastPromptTime: this._lastPromptTime, lastPromptTime: this._lastPromptTime,
+162 -66
View File
@@ -1171,6 +1171,7 @@ class ClaudemanApp {
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }> this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }> this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphStatePanelCollapsed = true; // Default to collapsed
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
@@ -1549,6 +1550,7 @@ class ClaudemanApp {
// PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug). // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug).
this._pendingInput = ''; this._pendingInput = '';
this._inputFlushTimeout = null; this._inputFlushTimeout = null;
this._lastKeystrokeTime = 0;
const flushInput = () => { const flushInput = () => {
this._inputFlushTimeout = null; this._inputFlushTimeout = null;
@@ -1580,10 +1582,23 @@ class ClaudemanApp {
return; return;
} }
// Regular chars — flush on next microtask to coalesce rapid keystrokes // Regular chars — flush immediately if typed after a gap (>50ms),
// (e.g. paste) while keeping latency near-zero for single keystrokes. // otherwise batch via microtask to coalesce rapid keystrokes (paste).
if (!this._inputFlushTimeout) { const now = performance.now();
this._inputFlushTimeout = setTimeout(flushInput, 0); if (now - this._lastKeystrokeTime > 50) {
// Single char after a gap — send immediately, no setTimeout latency
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
this._lastKeystrokeTime = now;
flushInput();
} else {
// Rapid sequence (paste or fast typing) — coalesce via microtask
this._lastKeystrokeTime = now;
if (!this._inputFlushTimeout) {
this._inputFlushTimeout = setTimeout(flushInput, 0);
}
} }
} }
}); });
@@ -2973,22 +2988,31 @@ class ClaudemanApp {
return; return;
} }
// Chain sends sequentially to preserve keystroke ordering // Chain on dispatch only — wait for the previous request to be sent before
this._inputSendChain = this._inputSendChain.then(async () => { // dispatching the next one (preserves keystroke ordering), but don't wait
try { // for the server's response. The server handles writeViaScreen as
const resp = await fetch(`/api/sessions/${sessionId}/input`, { // fire-and-forget anyway, so the HTTP response carries no useful data
method: 'POST', // beyond success/failure for retry purposes.
headers: { 'Content-Type': 'application/json' }, this._inputSendChain = this._inputSendChain.then(() => {
body: JSON.stringify({ input }) const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, {
}); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
keepalive: input.length < 65536,
});
// Handle response asynchronously — don't block next keystroke on response
fetchPromise.then(resp => {
if (!resp.ok) { if (!resp.ok) {
this._enqueueInput(sessionId, input); this._enqueueInput(sessionId, input);
} else { } else {
this.clearPendingHooks(sessionId); this.clearPendingHooks(sessionId);
} }
} catch { }).catch(() => {
this._enqueueInput(sessionId, input); this._enqueueInput(sessionId, input);
} });
// Return immediately after fetch is dispatched (don't await response)
}); });
} }
@@ -3097,6 +3121,7 @@ class ClaudemanApp {
this.sessions.clear(); this.sessions.clear();
this.ralphStates.clear(); this.ralphStates.clear();
this.terminalBuffers.clear(); this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.projectInsights.clear(); this.projectInsights.clear();
// Clear all idle timers to prevent stale timers from firing // Clear all idle timers to prevent stale timers from firing
for (const timer of this.idleTimers.values()) { for (const timer of this.idleTimers.values()) {
@@ -3284,6 +3309,20 @@ class ClaudemanApp {
}, 100); }, 100);
} }
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
_updateActiveTabImmediate(sessionId) {
const container = this.$('sessionTabs');
if (!container) return;
const tabs = container.querySelectorAll('.session-tab[data-id]');
for (const tab of tabs) {
if (tab.dataset.id === sessionId) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
}
}
_renderSessionTabsImmediate() { _renderSessionTabsImmediate() {
const container = this.$('sessionTabs'); const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]'); const existingTabs = container.querySelectorAll('.session-tab[data-id]');
@@ -3831,6 +3870,8 @@ class ClaudemanApp {
this.hideWelcome(); this.hideWelcome();
// Clear idle hooks on view, but keep action hooks until user interacts // Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt'); this.clearPendingHooks(sessionId, 'idle_prompt');
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
this.renderSessionTabs(); this.renderSessionTabs();
// Glow the newly-active tab // Glow the newly-active tab
@@ -3857,9 +3898,19 @@ class ClaudemanApp {
} }
// Load terminal buffer for this session // Load terminal buffer for this session
// Use tail mode for faster initial load (256KB is enough for recent visible content) // Show cached content instantly while fetching fresh data in background.
// Use tail mode for faster initial load (256KB is enough for recent visible content).
// Fetch runs in parallel with fade-out; we wait for both before clearing terminal. // Fetch runs in parallel with fade-out; we wait for both before clearing terminal.
try { try {
// Instant cache restore — show previous buffer immediately while network fetch runs
const cachedBuffer = this.terminalBufferCache.get(sessionId);
if (cachedBuffer) {
this.terminal.clear();
this.terminal.reset();
this.terminal.write(cachedBuffer);
this.terminal.scrollToBottom();
}
const tailSize = 256 * 1024; const tailSize = 256 * 1024;
const [res] = await Promise.all([ const [res] = await Promise.all([
fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`), fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`),
@@ -3880,61 +3931,76 @@ class ClaudemanApp {
if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates
// Ensure terminal is scrolled to bottom after buffer load // Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
// Update cache (cap at 20 entries)
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
if (this.terminalBufferCache.size > 20) {
// Evict oldest entry (first key in Map iteration order)
const oldest = this.terminalBufferCache.keys().next().value;
this.terminalBufferCache.delete(oldest);
}
} }
// Send resize and Ctrl+L to trigger Claude to redraw at correct size // Send resize and Ctrl+L to trigger Claude to redraw at correct size
await this.sendResize(sessionId); await this.sendResize(sessionId);
// Update respawn banner // Defer secondary panel updates so they don't block the main thread
if (this.respawnStatus[sessionId]) { // after terminal content is already visible.
this.showRespawnBanner(); const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
this.updateRespawnBanner(this.respawnStatus[sessionId].state); idleCb(() => {
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0; // Guard against stale generation — user may have switched tabs again
// Update countdown timers and action log for this session if (selectGen !== this._selectGeneration) return;
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay(); // Update respawn banner
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) { if (this.respawnStatus[sessionId]) {
this.startCountdownInterval(); this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
this.startCountdownInterval();
}
} else {
this.hideRespawnBanner();
this.stopCountdownInterval();
} }
} else {
this.hideRespawnBanner();
this.stopCountdownInterval();
}
// Update task panel if open // Update task panel if open
const taskPanel = document.getElementById('taskPanel'); const taskPanel = document.getElementById('taskPanel');
if (taskPanel && taskPanel.classList.contains('open')) { if (taskPanel && taskPanel.classList.contains('open')) {
this.renderTaskPanel(); this.renderTaskPanel();
}
// Update ralph state panel for this session
const session = this.sessions.get(sessionId);
if (session && (session.ralphLoop || session.ralphTodos)) {
this.updateRalphState(sessionId, {
loop: session.ralphLoop,
todos: session.ralphTodos
});
}
this.renderRalphStatePanel();
// Update CLI info bar (mobile - shows Claude version/model)
this.updateCliInfoDisplay();
// Update project insights panel for this session
this.renderProjectInsightsPanel();
// Update subagent window visibility for active session
this.updateSubagentWindowVisibility();
// Load file browser if enabled
const settings = this.loadAppSettingsFromStorage();
if (settings.showFileBrowser) {
const fileBrowserPanel = this.$('fileBrowserPanel');
if (fileBrowserPanel) {
fileBrowserPanel.classList.add('visible');
this.loadFileBrowser(sessionId);
} }
}
// Update ralph state panel for this session
const curSession = this.sessions.get(sessionId);
if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) {
this.updateRalphState(sessionId, {
loop: curSession.ralphLoop,
todos: curSession.ralphTodos
});
}
this.renderRalphStatePanel();
// Update CLI info bar (mobile - shows Claude version/model)
this.updateCliInfoDisplay();
// Update project insights panel for this session
this.renderProjectInsightsPanel();
// Update subagent window visibility for active session
this.updateSubagentWindowVisibility();
// Load file browser if enabled
const settings = this.loadAppSettingsFromStorage();
if (settings.showFileBrowser) {
const fileBrowserPanel = this.$('fileBrowserPanel');
if (fileBrowserPanel) {
fileBrowserPanel.classList.add('visible');
this.loadFileBrowser(sessionId);
}
}
});
this.terminal.focus(); this.terminal.focus();
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
@@ -3964,6 +4030,7 @@ class ClaudemanApp {
this.saveSessionOrder(); this.saveSessionOrder();
} }
this.terminalBuffers.delete(sessionId); this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
this.ralphStates.delete(sessionId); this.ralphStates.delete(sessionId);
this.clearCountdownTimers(sessionId); this.clearCountdownTimers(sessionId);
@@ -7548,10 +7615,13 @@ class ClaudemanApp {
} }
updateRespawnTokens(tokens) { updateRespawnTokens(tokens) {
const tokensEl = this.$('respawnTokens'); // Skip if tokens haven't changed (avoid unnecessary DOM writes)
// Support both old format (number) and new format (object with input/output/total)
const isObject = tokens && typeof tokens === 'object'; const isObject = tokens && typeof tokens === 'object';
const total = isObject ? tokens.total : tokens; const total = isObject ? tokens.total : tokens;
if (total === this._lastRespawnTokenTotal) return;
this._lastRespawnTokenTotal = total;
const tokensEl = this.$('respawnTokens');
const input = isObject ? (tokens.input || 0) : Math.round(total * 0.6); const input = isObject ? (tokens.input || 0) : Math.round(total * 0.6);
const output = isObject ? (tokens.output || 0) : Math.round(total * 0.4); const output = isObject ? (tokens.output || 0) : Math.round(total * 0.4);
@@ -7801,6 +7871,7 @@ class ClaudemanApp {
await fetch('/api/sessions', { method: 'DELETE' }); await fetch('/api/sessions', { method: 'DELETE' });
this.sessions.clear(); this.sessions.clear();
this.terminalBuffers.clear(); this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.activeSessionId = null; this.activeSessionId = null;
try { localStorage.removeItem('claudeman-active-session'); } catch {} try { localStorage.removeItem('claudeman-active-session'); } catch {}
this.respawnStatus = {}; this.respawnStatus = {};
@@ -8029,6 +8100,18 @@ class ClaudemanApp {
} }
updateTokens() { updateTokens() {
// Debounce at 200ms — token display is non-critical and shouldn't
// compete with input handling on the main thread
if (this._updateTokensTimeout) {
clearTimeout(this._updateTokensTimeout);
}
this._updateTokensTimeout = setTimeout(() => {
this._updateTokensTimeout = null;
this._updateTokensImmediate();
}, 200);
}
_updateTokensImmediate() {
// Use global stats if available (includes deleted sessions) // Use global stats if available (includes deleted sessions)
let totalInput = 0; let totalInput = 0;
let totalOutput = 0; let totalOutput = 0;
@@ -9363,20 +9446,27 @@ class ClaudemanApp {
} }
loadAppSettingsFromStorage() { loadAppSettingsFromStorage() {
// Return cached settings if available (avoids synchronous localStorage + JSON.parse
// on every SSE event — critical for input responsiveness)
if (this._cachedAppSettings) return this._cachedAppSettings;
try { try {
const key = this.getSettingsStorageKey(); const key = this.getSettingsStorageKey();
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved) { if (saved) {
return JSON.parse(saved); this._cachedAppSettings = JSON.parse(saved);
return this._cachedAppSettings;
} }
} catch (err) { } catch (err) {
console.error('Failed to load app settings:', err); console.error('Failed to load app settings:', err);
} }
// Return device-specific defaults // Return device-specific defaults
return this.getDefaultSettings(); this._cachedAppSettings = this.getDefaultSettings();
return this._cachedAppSettings;
} }
saveAppSettingsToStorage(settings) { saveAppSettingsToStorage(settings) {
// Invalidate cache on save
this._cachedAppSettings = settings;
try { try {
const key = this.getSettingsStorageKey(); const key = this.getSettingsStorageKey();
localStorage.setItem(key, JSON.stringify(settings)); localStorage.setItem(key, JSON.stringify(settings));
@@ -11740,6 +11830,12 @@ class ClaudemanApp {
const newName = this.getSessionName(session); const newName = this.getSessionName(session);
// Skip iteration if name hasn't changed (avoids O(n) loop on every session:updated)
const cachedName = this._parentNameCache?.get(sessionId);
if (cachedName === newName) return;
if (!this._parentNameCache) this._parentNameCache = new Map();
this._parentNameCache.set(sessionId, newName);
for (const [agentId, storedSessionId] of this.subagentParentMap) { for (const [agentId, storedSessionId] of this.subagentParentMap) {
if (storedSessionId === sessionId) { if (storedSessionId === sessionId) {
const agent = this.subagents.get(agentId); const agent = this.subagents.get(agentId);
+12 -3
View File
@@ -131,6 +131,9 @@ body {
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
z-index: 100; /* Above main content so dropdowns work */ z-index: 100; /* Above main content so dropdowns work */
/* Performance: isolate header from terminal reflow — tab badge updates
and token counter changes shouldn't trigger terminal layout recalculation */
contain: layout style paint;
} }
.header-brand { .header-brand {
@@ -204,7 +207,7 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: all 0.15s; transition: background 0.05s, border-color 0.05s, color 0.05s, box-shadow 0.05s;
} }
.session-tab:hover { .session-tab:hover {
@@ -228,7 +231,7 @@ body {
/* Tab switch feedback: bright green glow on the newly-active tab */ /* Tab switch feedback: bright green glow on the newly-active tab */
.session-tab.tab-glow { .session-tab.tab-glow {
animation: tab-glow 1s ease-out forwards; animation: tab-glow 0.35s ease-out forwards;
} }
@keyframes tab-glow { @keyframes tab-glow {
@@ -288,7 +291,7 @@ body {
cursor: default; cursor: default;
border-radius: 3px; border-radius: 3px;
/* Performance: use transform for GPU acceleration */ /* Performance: use transform for GPU acceleration */
transition: opacity 0.15s ease-out, width 0.15s ease-out, padding 0.15s ease-out; transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
} }
.session-tab:hover .tab-close { .session-tab:hover .tab-close {
@@ -1674,6 +1677,10 @@ body {
Do NOT use contain:strict — size containment breaks xterm.js Do NOT use contain:strict — size containment breaks xterm.js
FitAddon dimension calculations. */ FitAddon dimension calculations. */
contain: layout paint; contain: layout paint;
/* Performance: promote to own compositor layer for isolation from
header/toolbar/panel repaints. Eliminates compositing stalls
when surrounding UI elements update during typing. */
will-change: contents;
} }
/* Tab switch: green-tinted overlay fades in over old content, fades out to reveal new. /* Tab switch: green-tinted overlay fades in over old content, fades out to reveal new.
Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing. Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing.
@@ -1812,6 +1819,8 @@ body {
background: var(--bg-card); background: var(--bg-card);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Performance: isolate from terminal — toolbar updates shouldn't trigger terminal reflow */
contain: layout style paint;
} }
.toolbar-left, .toolbar-left,
+4 -10
View File
@@ -4314,7 +4314,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
private getSessionStateWithRespawn(session: Session) { private getSessionStateWithRespawn(session: Session) {
const controller = this.respawnControllers.get(session.id); const controller = this.respawnControllers.get(session.id);
return { return {
...session.toDetailedState(), ...session.toLightDetailedState(),
respawnEnabled: controller?.getConfig()?.enabled ?? false, respawnEnabled: controller?.getConfig()?.enabled ?? false,
respawnConfig: controller?.getConfig() ?? null, respawnConfig: controller?.getConfig() ?? null,
respawn: controller?.getStatus() ?? null, respawn: controller?.getStatus() ?? null,
@@ -4327,15 +4327,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
* on-demand when switching tabs via /api/sessions/:id/buffer * on-demand when switching tabs via /api/sessions/:id/buffer
*/ */
private getLightSessionsState() { private getLightSessionsState() {
return Array.from(this.sessions.values()).map(s => { // getSessionStateWithRespawn already uses toLightDetailedState() which
const state = this.getSessionStateWithRespawn(s); // excludes terminalBuffer and textOutput — no extra stripping needed
return { return Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s));
...state,
// Exclude full buffers - they're fetched on-demand
terminalBuffer: '',
textOutput: '',
};
});
} }
// Clean up old completed scheduled runs // Clean up old completed scheduled runs