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`)
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"main": "dist/index.js",
+13 -2
View File
@@ -754,14 +754,25 @@ export class Session extends EventEmitter {
}
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 {
...this.toState(),
name: this._name,
mode: this.mode,
claudeSessionId: this._claudeSessionId,
totalCost: this._totalCost,
textOutput: this._textOutput.value,
terminalBuffer: this._terminalBuffer.value,
messageCount: this._messages.length,
isWorking: this._isWorking,
lastPromptTime: this._lastPromptTime,
+162 -66
View File
@@ -1171,6 +1171,7 @@ class ClaudemanApp {
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
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.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).
this._pendingInput = '';
this._inputFlushTimeout = null;
this._lastKeystrokeTime = 0;
const flushInput = () => {
this._inputFlushTimeout = null;
@@ -1580,10 +1582,23 @@ class ClaudemanApp {
return;
}
// Regular chars — flush on next microtask to coalesce rapid keystrokes
// (e.g. paste) while keeping latency near-zero for single keystrokes.
if (!this._inputFlushTimeout) {
this._inputFlushTimeout = setTimeout(flushInput, 0);
// Regular chars — flush immediately if typed after a gap (>50ms),
// otherwise batch via microtask to coalesce rapid keystrokes (paste).
const now = performance.now();
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;
}
// Chain sends sequentially to preserve keystroke ordering
this._inputSendChain = this._inputSendChain.then(async () => {
try {
const resp = await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input })
});
// 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 writeViaScreen 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,
});
// Handle response asynchronously — don't block next keystroke on response
fetchPromise.then(resp => {
if (!resp.ok) {
this._enqueueInput(sessionId, input);
} else {
this.clearPendingHooks(sessionId);
}
} catch {
}).catch(() => {
this._enqueueInput(sessionId, input);
}
});
// Return immediately after fetch is dispatched (don't await response)
});
}
@@ -3097,6 +3121,7 @@ class ClaudemanApp {
this.sessions.clear();
this.ralphStates.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.projectInsights.clear();
// Clear all idle timers to prevent stale timers from firing
for (const timer of this.idleTimers.values()) {
@@ -3284,6 +3309,20 @@ class ClaudemanApp {
}, 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() {
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
@@ -3831,6 +3870,8 @@ class ClaudemanApp {
this.hideWelcome();
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
this.renderSessionTabs();
// Glow the newly-active tab
@@ -3857,9 +3898,19 @@ class ClaudemanApp {
}
// 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.
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 [res] = await Promise.all([
fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`),
@@ -3880,61 +3931,76 @@ class ClaudemanApp {
if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates
// Ensure terminal is scrolled to bottom after buffer load
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
await this.sendResize(sessionId);
// Update respawn banner
if (this.respawnStatus[sessionId]) {
this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
// Update countdown timers and action log for this session
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
this.startCountdownInterval();
// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
idleCb(() => {
// Guard against stale generation — user may have switched tabs again
if (selectGen !== this._selectGeneration) return;
// Update respawn banner
if (this.respawnStatus[sessionId]) {
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
const taskPanel = document.getElementById('taskPanel');
if (taskPanel && taskPanel.classList.contains('open')) {
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 task panel if open
const taskPanel = document.getElementById('taskPanel');
if (taskPanel && taskPanel.classList.contains('open')) {
this.renderTaskPanel();
}
}
// 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.scrollToBottom();
@@ -3964,6 +4030,7 @@ class ClaudemanApp {
this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
this.ralphStates.delete(sessionId);
this.clearCountdownTimers(sessionId);
@@ -7548,10 +7615,13 @@ class ClaudemanApp {
}
updateRespawnTokens(tokens) {
const tokensEl = this.$('respawnTokens');
// Support both old format (number) and new format (object with input/output/total)
// Skip if tokens haven't changed (avoid unnecessary DOM writes)
const isObject = tokens && typeof tokens === 'object';
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 output = isObject ? (tokens.output || 0) : Math.round(total * 0.4);
@@ -7801,6 +7871,7 @@ class ClaudemanApp {
await fetch('/api/sessions', { method: 'DELETE' });
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.activeSessionId = null;
try { localStorage.removeItem('claudeman-active-session'); } catch {}
this.respawnStatus = {};
@@ -8029,6 +8100,18 @@ class ClaudemanApp {
}
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)
let totalInput = 0;
let totalOutput = 0;
@@ -9363,20 +9446,27 @@ class ClaudemanApp {
}
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 {
const key = this.getSettingsStorageKey();
const saved = localStorage.getItem(key);
if (saved) {
return JSON.parse(saved);
this._cachedAppSettings = JSON.parse(saved);
return this._cachedAppSettings;
}
} catch (err) {
console.error('Failed to load app settings:', err);
}
// Return device-specific defaults
return this.getDefaultSettings();
this._cachedAppSettings = this.getDefaultSettings();
return this._cachedAppSettings;
}
saveAppSettingsToStorage(settings) {
// Invalidate cache on save
this._cachedAppSettings = settings;
try {
const key = this.getSettingsStorageKey();
localStorage.setItem(key, JSON.stringify(settings));
@@ -11740,6 +11830,12 @@ class ClaudemanApp {
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) {
if (storedSessionId === sessionId) {
const agent = this.subagents.get(agentId);
+12 -3
View File
@@ -131,6 +131,9 @@ body {
flex-shrink: 0;
position: relative;
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 {
@@ -204,7 +207,7 @@ body {
font-size: 0.75rem;
cursor: pointer;
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 {
@@ -228,7 +231,7 @@ body {
/* Tab switch feedback: bright green glow on the newly-active tab */
.session-tab.tab-glow {
animation: tab-glow 1s ease-out forwards;
animation: tab-glow 0.35s ease-out forwards;
}
@keyframes tab-glow {
@@ -288,7 +291,7 @@ body {
cursor: default;
border-radius: 3px;
/* 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 {
@@ -1674,6 +1677,10 @@ body {
Do NOT use contain:strict — size containment breaks xterm.js
FitAddon dimension calculations. */
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.
Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing.
@@ -1812,6 +1819,8 @@ body {
background: var(--bg-card);
border-top: 1px solid var(--border);
flex-shrink: 0;
/* Performance: isolate from terminal — toolbar updates shouldn't trigger terminal reflow */
contain: layout style paint;
}
.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) {
const controller = this.respawnControllers.get(session.id);
return {
...session.toDetailedState(),
...session.toLightDetailedState(),
respawnEnabled: controller?.getConfig()?.enabled ?? false,
respawnConfig: controller?.getConfig() ?? 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
*/
private getLightSessionsState() {
return Array.from(this.sessions.values()).map(s => {
const state = this.getSessionStateWithRespawn(s);
return {
...state,
// Exclude full buffers - they're fetched on-demand
terminalBuffer: '',
textOutput: '',
};
});
// getSessionStateWithRespawn already uses toLightDetailedState() which
// excludes terminalBuffer and textOutput — no extra stripping needed
return Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s));
}
// Clean up old completed scheduled runs