diff --git a/CLAUDE.md b/CLAUDE.md index d676fcba..7fc7682e 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.1570 (must match `package.json` for npm publish) +**Version**: 0.1571 (must match `package.json` for npm publish) ## Project Overview diff --git a/docs/images/subagent-demo.mp4 b/docs/images/subagent-demo.mp4 new file mode 100644 index 00000000..44c4209d Binary files /dev/null and b/docs/images/subagent-demo.mp4 differ diff --git a/package.json b/package.json index 418e2990..ba1098e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1570", + "version": "0.1571", "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 39c77c6e..24025236 100644 --- a/src/session.ts +++ b/src/session.ts @@ -789,9 +789,9 @@ export class Session extends EventEmitter { maxTextOutput: MAX_TEXT_OUTPUT_SIZE, maxMessages: MAX_MESSAGES, }, - // Background task tracking + // Background task tracking (light tree strips large output strings) taskStats: this._taskTracker.getStats(), - taskTree: this._taskTracker.getTaskTree(), + taskTree: this._taskTracker.getTaskTreeLight(), // Token tracking tokens: { input: this._totalInputTokens, diff --git a/src/task-tracker.ts b/src/task-tracker.ts index 33e1b48c..6006569e 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -547,6 +547,27 @@ export class TaskTracker extends EventEmitter { return rootTasks; } + /** + * Lightweight task tree for SSE broadcasts — strips large `output` strings + * to avoid serializing 5-10MB of task results every 500ms. + * Full task details available via getTaskTree(). + * + * @returns Array of root tasks with output truncated to 200 chars + */ + getTaskTreeLight(): Omit[] { + const rootTasks: Omit[] = []; + + for (const task of this.tasks.values()) { + if (!task.parentId) { + // Strip output to avoid serializing large strings on every broadcast + const { output: _output, ...lightTask } = task; + rootTasks.push(lightTask); + } + } + + return rootTasks; + } + /** * Get all tasks as a flat Map. * diff --git a/src/types.ts b/src/types.ts index 25fdb41f..c257706d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -692,16 +692,12 @@ export function createErrorResponse(code: ApiErrorCode, details?: string): ApiRe export interface SessionResponse { /** Whether the request succeeded */ success: boolean; - /** Session details if successful */ + /** Session details if successful (light state — no full buffers) */ session?: SessionState & { /** Claude session ID from CLI */ claudeSessionId: string | null; /** Total API cost */ totalCost: number; - /** Text output buffer */ - textOutput: string; - /** Terminal buffer */ - terminalBuffer: string; /** Number of messages */ messageCount: number; /** Whether Claude is working */ diff --git a/src/web/public/app.js b/src/web/public/app.js index 125aa413..1e3a0c38 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -7716,7 +7716,8 @@ class ClaudemanApp { while (attempts < maxAttempts) { await new Promise(r => setTimeout(r, 500)); try { - const statusRes = await fetch(`/api/sessions/${sessionId}`); + // Use /terminal?tail=2048 instead of /sessions/:id to avoid fetching 2-3MB full state + const statusRes = await fetch(`/api/sessions/${sessionId}/terminal?tail=2048`); const statusData = await statusRes.json(); // Session is ready ONLY when Claude CLI shows its UI: // Must see prompt character '❯' OR 'tokens' status line @@ -7781,9 +7782,14 @@ class ClaudemanApp { for (let attempt = 0; attempt < initMaxAttempts; attempt++) { await new Promise(r => setTimeout(r, 1000)); try { - const statusRes = await fetch(`/api/sessions/${sessionId}`); - const statusData = await statusRes.json(); - const termBuf = statusData?.terminalBuffer || ''; + // Fetch terminal tail (lightweight) + session state separately to avoid 2-3MB full buffer + const [termRes, stateRes] = await Promise.all([ + fetch(`/api/sessions/${sessionId}/terminal?tail=2048`), + fetch(`/api/sessions/${sessionId}`) + ]); + const termData = await termRes.json(); + const statusData = await stateRes.json(); + const termBuf = termData?.terminalBuffer || ''; // /init is complete when we see the prompt indicator (❯) and not working const hasPrompt = termBuf.includes('❯'); const isIdle = !statusData.isWorking; diff --git a/src/web/server.ts b/src/web/server.ts index 6aa14235..60b94d43 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -47,7 +47,6 @@ import { ApiErrorCode, createErrorResponse, type ApiResponse, - type SessionResponse, type QuickStartResponse, type CaseInfo, type PersistedRespawnConfig, @@ -809,7 +808,7 @@ export class WebServer extends EventEmitter { // Session management this.app.get('/api/sessions', async () => this.getLightSessionsState()); - this.app.post('/api/sessions', async (req): Promise => { + this.app.post('/api/sessions', async (req) => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`); @@ -858,9 +857,11 @@ export class WebServer extends EventEmitter { await this.setupSessionListeners(session); getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); - const detailedState = session.toDetailedState(); - this.broadcast('session:created', detailedState); - return { success: true, session: detailedState }; + // Use light state for broadcast + response — buffers are fetched on-demand via /terminal. + // Avoids serializing 2-3MB of terminal+text buffers per session creation. + const lightState = this.getSessionStateWithRespawn(session); + this.broadcast('session:created', lightState); + return { success: true, session: lightState }; }); // Rename a session @@ -947,14 +948,9 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - // Include respawn controller state if active - const controller = this.respawnControllers.get(id); - return { - ...session.toDetailedState(), - respawnEnabled: controller?.getConfig()?.enabled ?? false, - respawnConfig: controller?.getConfig() ?? null, - respawn: controller?.getStatus() ?? null, - }; + // Use light state (no full buffers) — terminal buffer available via /terminal endpoint. + // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard). + return this.getSessionStateWithRespawn(session); }); this.app.get('/api/sessions/:id/output', async (req) => { @@ -2242,7 +2238,7 @@ export class WebServer extends EventEmitter { await this.setupSessionListeners(session); getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'run_prompt' }); - this.broadcast('session:created', session.toDetailedState()); + this.broadcast('session:created', this.getSessionStateWithRespawn(session)); try { const result = await session.runPrompt(prompt); @@ -2660,7 +2656,7 @@ export class WebServer extends EventEmitter { this.persistSessionState(session); await this.setupSessionListeners(session); getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'quick_start' }); - this.broadcast('session:created', session.toDetailedState()); + this.broadcast('session:created', this.getSessionStateWithRespawn(session)); // Start in the appropriate mode try {