From 0fe6251424c7465bcbce5d61a79388f708d592b1 Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 21 Feb 2026 07:12:57 +0100 Subject: [PATCH] chore: bump version to 0.1585 --- CLAUDE.md | 2 +- package.json | 2 +- src/session.ts | 6 +-- src/team-watcher.ts | 6 +-- src/utils/index.ts | 1 + src/utils/regex-patterns.ts | 7 ++++ src/web/public/styles.css | 15 +------- test/perf-subagent-load.test.ts | 66 ++++++++++++++++----------------- 8 files changed, 48 insertions(+), 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97c43320..ae294e9a 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.1584 (must match `package.json` for npm publish) +**Version**: 0.1585 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 76052c91..2704fb81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1584", + "version": "0.1585", "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/session.ts b/src/session.ts index 60f75752..4a7190e1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -28,6 +28,7 @@ import { LRUMap } from './utils/lru-map.js'; import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, + SPINNER_PATTERN, MAX_SESSION_TOKENS, } from './utils/index.js'; import { @@ -1036,10 +1037,7 @@ export class Session extends EventEmitter { // Detect when Claude starts working (thinking, writing, etc) // Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences) - const hasSpinner = data.includes('⠋') || data.includes('⠙') || - data.includes('⠹') || data.includes('⠸') || - data.includes('⠼') || data.includes('⠴') || - data.includes('⠦') || data.includes('⠧'); + const hasSpinner = SPINNER_PATTERN.test(data); if (hasSpinner) { if (!this._isWorking) { this._isWorking = true; diff --git a/src/team-watcher.ts b/src/team-watcher.ts index 06e84a02..d6346699 100644 --- a/src/team-watcher.ts +++ b/src/team-watcher.ts @@ -74,7 +74,7 @@ export class TeamWatcher extends EventEmitter { getTeamForSession(sessionId: string): TeamConfig | undefined { const teamName = this.sessionToTeam.get(sessionId); if (teamName) { - const team = this.teams.get(teamName); + const team = this.teams.peek(teamName); if (team) return team; // Stale reverse index entry — clean up this.sessionToTeam.delete(sessionId); @@ -210,7 +210,7 @@ export class TeamWatcher extends EventEmitter { } // Detect removed teams - for (const name of Array.from(this.teams.keys())) { + for (const name of this.teams.keys()) { if (!currentTeamNames.has(name)) { const removed = this.teams.get(name); this.teams.delete(name); @@ -221,7 +221,7 @@ export class TeamWatcher extends EventEmitter { } // Prune stale mtime entries for removed teams this.taskMtimes.delete(name); - for (const key of Array.from(this.inboxMtimes.keys())) { + for (const key of this.inboxMtimes.keys()) { if (key.startsWith(`${name}/`)) { this.inboxMtimes.delete(key); } diff --git a/src/utils/index.ts b/src/utils/index.ts index d5ed6e06..cf186a0b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,6 +14,7 @@ export { ANSI_ESCAPE_PATTERN_FULL, ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, + SPINNER_PATTERN, createAnsiPatternFull, createAnsiPatternSimple, stripAnsi, diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts index 2fb409b7..14ec9894 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -67,5 +67,12 @@ export function stripAnsi(text: string): string { * Regex to validate safe file paths (no shell metacharacters). * Shared by schemas.ts and tmux-manager.ts for consistent path validation. */ +/** + * Braille spinner characters used by Claude Code's Ink UI. + * Matches any of: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ + * Used on the hot path (every PTY chunk) — pre-compiled for performance. + */ +export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; + export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index dc916b25..5e42ceba 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -189,6 +189,7 @@ body { .session-tabs::-webkit-scrollbar { width: 4px; + height: 0; } .session-tabs::-webkit-scrollbar-track { @@ -204,10 +205,6 @@ body { background: var(--border-light); } -.session-tabs::-webkit-scrollbar { - height: 0; -} - .session-tab { display: flex; align-items: center; @@ -795,10 +792,6 @@ body { color: var(--accent); } -.respawn-action-entry.action-command .action-detail { - color: var(--accent-hover); -} - .respawn-action-entry.action-command .action-type { color: var(--accent); font-weight: 600; @@ -1384,6 +1377,7 @@ body { border-radius: 4px; border-left: 2px solid var(--text-muted); transition: border-color 0.2s, background 0.2s; + position: relative; } .ralph-task-card.task-pending { @@ -7315,11 +7309,6 @@ kbd { background: var(--bg-card); } -/* Runtime task card enhancements */ -.ralph-task-card { - position: relative; -} - .ralph-task-attempts { position: absolute; top: 2px; diff --git a/test/perf-subagent-load.test.ts b/test/perf-subagent-load.test.ts index ae703c17..84d6fb5a 100644 --- a/test/perf-subagent-load.test.ts +++ b/test/perf-subagent-load.test.ts @@ -220,7 +220,7 @@ describe('SubagentWatcher performance', () => { }); describe('extractDescriptionFromFile — full subagent file reads', () => { - it('should not read entire large agent file just for first 5 lines', async () => { + it('should extract description from first 8KB instead of reading entire file', async () => { const agentDir = join(tmpDir, 'agent-files'); mkdirSync(agentDir, { recursive: true }); @@ -232,7 +232,7 @@ describe('SubagentWatcher performance', () => { const fileStat = await statAsync(agentFile); const fileSizeKB = fileStat.size / 1024; - // Current implementation: reads ENTIRE file then slices first 5 lines + // Approach 1: Read entire file then slice first 5 lines const start = performance.now(); const content = await readFile(agentFile, 'utf8'); const lines = content.split('\n').filter((l: string) => l.trim()); @@ -253,48 +253,44 @@ describe('SubagentWatcher performance', () => { } const readAllElapsed = performance.now() - start; - // Compare with streaming approach (only read first N bytes) - const { createReadStream } = await import('node:fs'); - const { createInterface } = await import('node:readline'); - + // Approach 2: Read only first 8KB via partial read const start2 = performance.now(); + const fd = await import('node:fs/promises').then(m => m.open(agentFile, 'r')); + const buf = Buffer.alloc(8192); + const { bytesRead } = await fd.read(buf, 0, 8192, 0); + await fd.close(); + const partial = buf.subarray(0, bytesRead).toString('utf8'); + const partialLines = partial.split('\n').filter((l: string) => l.trim()); let description2: string | undefined; - await new Promise((resolve) => { - const stream = createReadStream(agentFile, { end: 8192 }); // Only read first 8KB - const rl = createInterface({ input: stream }); - let lineCount = 0; - rl.on('line', (line) => { - if (lineCount >= 5) { rl.close(); stream.destroy(); return; } - lineCount++; - try { - const entry = JSON.parse(line); - if (entry.type === 'user' && entry.message?.content) { - const firstContent = Array.isArray(entry.message.content) - ? entry.message.content[0] - : undefined; - if (firstContent?.type === 'text') { - description2 = firstContent.text.trim().slice(0, 45); - rl.close(); - stream.destroy(); - } + for (const line of partialLines.slice(0, 5)) { + try { + const entry = JSON.parse(line); + if (entry.type === 'user' && entry.message?.content) { + const firstContent = Array.isArray(entry.message.content) + ? entry.message.content[0] + : undefined; + if (firstContent?.type === 'text') { + description2 = firstContent.text.trim().slice(0, 45); } - } catch { /* skip */ } - }); - rl.on('close', resolve); - rl.on('error', resolve); - }); - const streamElapsed = performance.now() - start2; + } + } catch { /* skip */ } + } + const partialElapsed = performance.now() - start2; - console.log(`[extractDescription] file: ${fileSizeKB.toFixed(0)}KB, readAll: ${readAllElapsed.toFixed(1)}ms, stream-first-8KB: ${streamElapsed.toFixed(1)}ms`); - console.log(`[extractDescription] speedup: ${(readAllElapsed / Math.max(streamElapsed, 0.1)).toFixed(1)}x`); + console.log(`[extractDescription] file: ${fileSizeKB.toFixed(0)}KB, readAll: ${readAllElapsed.toFixed(1)}ms, partial-8KB: ${partialElapsed.toFixed(1)}ms`); + console.log(`[extractDescription] readAll bytes: ${content.length}, partial bytes: ${bytesRead}`); + // Both approaches must find the same description expect(description).toBeDefined(); expect(description2).toBeDefined(); expect(description).toBe(description2); - // Stream approach should be significantly faster on large files - // This test documents the waste in the current approach - expect(streamElapsed).toBeLessThan(readAllElapsed); + // The partial read should touch far less data than readAll + expect(bytesRead).toBeLessThan(fileStat.size / 10); + + // Both approaches should complete quickly (under 50ms for cached files) + expect(readAllElapsed).toBeLessThan(50); + expect(partialElapsed).toBeLessThan(50); }); });