From 49e9b9e8c965fdb7f7402c3b381f6d344c78a176 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 3 Mar 2026 22:56:40 +0100 Subject: [PATCH] chore: version packages --- CHANGELOG.md | 6 ++ CLAUDE.md | 141 ++++++++--------------------- package.json | 2 +- src/prompts/planner.ts | 15 ++- src/prompts/research-agent.ts | 10 +- src/ralph-tracker.ts | 21 ++++- src/web/public/app.js | 85 ++++++++++++----- src/web/public/subagent-windows.js | 26 ++++-- src/web/server.ts | 27 +++--- 9 files changed, 173 insertions(+), 160 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08fc23d5..e5875524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # aicodeman +## 0.3.1 + +### Patch Changes + +- LLM context optimization and performance improvements: compress CLAUDE.md 21%, MEMORY.md 61%; SSE broadcast early return, cached tunnel state, cache invalidation fix, ralph todo cleanup timer; frontend SSE listener leak fix, short ID caching, subagent window handle cleanup; 100% @fileoverview coverage + ## 0.3.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index 9d9bb3fe..9f49bfbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ When user says "COM": 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.3.0 (must match `package.json`) +**Version**: 0.3.1 (must match `package.json`) ## Project Overview @@ -84,20 +84,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ## Common Gotchas -- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink -- **Don't kill tmux sessions blindly** — Check `$CODEMAN_MUX` first; you might be inside one -- **Global regex `lastIndex` sharing** — `ANSI_ESCAPE_PATTERN_FULL/SIMPLE` have `g` flag; use `createAnsiPatternFull/Simple()` factory functions for fresh instances in loops -- **DEC 2026 sync blocks** — Never discard incomplete sync blocks (START without END); buffer up to 50ms then flush. See `app.js:extractSyncSegments()` -- **Terminal writes during buffer load** — Live SSE writes are queued while `_isLoadingBuffer` is true to prevent interleaving with historical data -- **Local echo prompt scanning** — Does NOT use `buffer.cursorY` (Ink moves it); scans buffer bottom-up for visible `>` prompt marker -- **ESM dynamic imports** — Never use `require()` in this codebase; it breaks in production ESM builds. Use `await import()` for dynamic imports. (`tsx` masks this in dev by shimming CJS/ESM) -- **Package name vs product name** — npm package is `aicodeman`, product is **Codeman**. Release workflow renames `aicodeman@X.Y.Z` tags to `codeman@X.Y.Z` +- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink +- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks +- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly +- **Global regex `lastIndex`** — Use `createAnsiPatternFull/Simple()` factories, not shared `g`-flag patterns in loops -## Import Conventions - -- **Utilities**: Import from `./utils` (re-exports all): `import { LRUMap, stripAnsi } from './utils'` -- **Types**: Use type imports from barrel: `import type { SessionState } from './types'` (re-exports from `src/types/` domain files) -- **Config**: Import from specific files: `import { MAX_TERMINAL_BUFFER_SIZE } from './config/buffer-limits'` +**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files. ## Architecture @@ -105,28 +97,28 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Domain | Key files | Notes | |--------|-----------|-------| -| **Entry** | `src/index.ts`, `src/cli.ts` | CLI entry point, global error recovery | -| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts` | PTY wrapper, lifecycle, auto-compact | -| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | tmux abstraction layer | -| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Autonomous cycling state machine | -| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Completion tracking, autonomous task loop | -| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | Background agent monitoring | -| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | AI-powered idle/plan detection | -| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | Task model, priority queue | -| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | Persistence, timeline, audit log | -| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | Hooks, push, tunnel, file watching | -| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | 2-agent plan generation | -| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | Fastify server, SSE event registry, REST API | -| **Frontend** | `src/web/public/app.js` ★ (~11.5K lines) + 8 JS modules | xterm.js UI, tabs, settings | -| **Types** | `src/types/index.ts` → 13 domain files | Barrel re-export, see `@fileoverview` in index.ts | +| **Entry** | `src/index.ts`, `src/cli.ts` | | +| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts` | | +| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | | +| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | +| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | +| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | | +| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | | +| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | | +| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | | +| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | +| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | +| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | +| **Frontend** | `src/web/public/app.js` ★ (~11.7K lines) + 9 JS modules | | +| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts | -★ = Large file (>50KB), contains complex state machines. Read `docs/respawn-state-machine.md` before modifying respawn/ralph. +★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. -**Local package**: `packages/xterm-zerolag-input/` — instant keystroke feedback overlay for xterm.js. Source of truth for `LocalEchoOverlay`; copy embedded in `app.js`. Build: `npm run build` (tsup). +**Local package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`. -**Config**: `src/config/` — 9 files (buffer limits, map limits, timeouts, SSE timing, auth, tunnel, terminal, AI, teams). Import from specific files. +**Config**: `src/config/` — 9 files. Import from specific files, not barrel. -**Utilities**: `src/utils/` — re-exported via `src/utils/index.ts`. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `createAnsiPatternFull/Simple()`, `assertNever`, `Debouncer`. +**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`. ### Data Flow @@ -176,24 +168,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ~100 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync. -### API Route Categories +### API Routes -~111 route handlers in `src/web/routes/`. Key groups: - -| Group | Prefix | Count | Key endpoints | -|-------|--------|-------|---------------| -| System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings`, `/api/subagents` | 35 | App state, config, subagents | -| Sessions | `/api/sessions` | 24 | CRUD, input, resize, interactive, shell | -| Ralph | `/api/sessions/:id/ralph-*` | 9 | state, status, config, circuit-breaker | -| Plan | `/api/sessions/:id/plan/*` | 8 | task CRUD, checkpoint, history, rollback | -| Respawn | `/api/sessions/:id/respawn` | 7 | start, stop, enable, config | -| Cases | `/api/cases` | 7 | CRUD, link, fix-plan | -| Files | `/api/sessions/:id/file*`, `tail-file` | 5 | Browser, preview, raw, tail stream | -| Mux | `/api/mux-sessions` | 5 | tmux management, stats | -| Scheduled | `/api/scheduled` | 4 | CRUD for scheduled runs | -| Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe | -| Teams | `/api/teams` | 2 | list teams, get team tasks | -| Hooks | `/api/hook-event` | 1 | Hook event ingestion | +~111 handlers across 13 route files in `src/web/routes/`: system (35), sessions (24), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1). Each file has `@fileoverview` with endpoint details. ## Adding Features @@ -208,18 +185,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ## State Files -| File | Purpose | -|------|---------| -| `~/.codeman/state.json` | Sessions, settings, tokens, respawn config | -| `~/.codeman/mux-sessions.json` | Tmux session metadata for recovery | -| `~/.codeman/settings.json` | User preferences | -| `~/.codeman/push-keys.json` | VAPID key pair for Web Push (auto-generated) | -| `~/.codeman/push-subscriptions.json` | Push notification subscriptions | -| `~/.codeman/session-lifecycle.jsonl` | Append-only audit log (QR auth, session events) | - -## Default Settings - -UI defaults in `app.js` using `??` fallbacks. Edit `openAppSettings()` and `apply*Visibility()` to change. Key defaults: most panels hidden (monitor, subagents shown), notifications on (audio off), subagent tracking on, Ralph tracking off. +All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log). ## Testing @@ -241,69 +207,40 @@ npx vitest run -t "pattern" # By name (SAFE) ## Screenshots -"sc"/"screenshot" = uploaded mobile screenshots in `~/.codeman/screenshots/`. View with Read tool. API: `GET /api/screenshots` (list), `POST /api/screenshots` (upload). +Mobile screenshots in `~/.codeman/screenshots/`. API: `GET /api/screenshots`, `POST /api/screenshots`. -## Debugging & Troubleshooting +## Debugging ```bash tmux list-sessions # List tmux sessions curl localhost:3000/api/sessions | jq # Check sessions curl localhost:3000/api/status | jq # Full app state curl localhost:3000/api/subagents | jq # Background agents -curl localhost:3000/api/sessions/:id/run-summary | jq # Session timeline cat ~/.codeman/state.json | jq # Persisted state ``` -| Problem | Fix | -|---------|-----| -| Session won't start | Kill orphaned tmux sessions, check Claude CLI installed | -| Port 3000 in use | `lsof -i :3000`, kill conflicting process or use `--port` | -| SSE not connecting | Check CORS, ensure server running, check browser console | -| Respawn not triggering | Enable respawn in session settings, check idle timeout | -| Terminal blank on tab switch | Check session exists, restart server | -| Tests failing on session limits | `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` | +## Performance & Limits -## Performance & Resource Limits - -Must stay fast with 20 sessions and 50 agent windows. Key: 60fps terminal (16ms batching + rAF), auto-trimming buffers, debounced state persistence (500ms), SSE adaptive batching (16-50ms), backpressure handling, cached endpoints (1s TTL for `/api/sessions` and `/api/status`). - -**Anti-flicker**: `PTY → Server Batching → DEC 2026 Wrap → SSE → Client rAF → xterm.js`. See `docs/terminal-anti-flicker.md`. - -**Limits** in `src/config/` (buffer-limits.ts, map-limits.ts, etc.). Key: terminal 2MB/1.5MB trim, text 1MB/768KB, messages 1000/800, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. ## References -| Topic | Location | -|-------|----------| -| Respawn state machine | `docs/respawn-state-machine.md` | -| Ralph Loop guide | `docs/ralph-wiggum-guide.md` | -| Claude Code hooks | `docs/claude-code-hooks-reference.md` | -| Terminal anti-flicker | `docs/terminal-anti-flicker.md` | -| Agent Teams | `agent-teams/README.md`, `agent-teams/design.md` | -| OpenCode integration | `docs/opencode-integration.md` | -| QR auth design | `docs/qr-auth-plan.md` | -| SSE events | `src/web/sse-events.ts` + `constants.js` | -| Types architecture | `src/types/index.ts` `@fileoverview` | -| API routes | `src/web/routes/` — each file has `@fileoverview` | +Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`. ## Scripts -Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel start/stop/url), `scripts/monitor-respawn.sh` (respawn monitoring), `scripts/watch-subagents.ts` (transcript watcher). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. +Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel start/stop/url). Production: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. ## Memory Leak Prevention -24+ hour sessions require cleanup of all Maps/timers. Backend: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`, SSE reconnect resets via `handleInit()`. Verify: `npx vitest run test/memory-leak-prevention.test.ts`. +24+ hour sessions: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npx vitest run test/memory-leak-prevention.test.ts`. ## Common Workflows -**Investigating a bug**: Start dev server, reproduce in browser, check terminal + `~/.codeman/state.json`. +**Bug investigation**: Dev server → reproduce in browser → check terminal + `~/.codeman/state.json`. +**API endpoint**: Types in `src/types/*.ts` → route in `src/web/routes/*-routes.ts` → SSE event if needed → handle in `app.js`. +**Respawn changes**: Read `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`. -**Adding an API endpoint**: Types in `src/types/*.ts`, route in `src/web/routes/*-routes.ts`, broadcast SSE if needed, handle in `app.js:handleSSEEvent()`. +## Tunnel -**Modifying respawn**: Study `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`. - -**Modifying mobile**: Singletons have `init()`/`cleanup()` lifecycle. Re-initialized after SSE reconnect to prevent stale closures. - -## Tunnel Setup - -Remote access via Cloudflare quick tunnel: `./scripts/tunnel.sh start|stop|url`. Web UI: Settings → Tunnel. Persistent: `systemctl --user enable --now codeman-tunnel`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel. +`./scripts/tunnel.sh start|stop|url`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel. diff --git a/package.json b/package.json index cfcff619..d87deaf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.3.0", + "version": "0.3.1", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/prompts/planner.ts b/src/prompts/planner.ts index 3ad2aaf1..05fda82d 100644 --- a/src/prompts/planner.ts +++ b/src/prompts/planner.ts @@ -1,14 +1,13 @@ /** - * Planner Prompt - Single agent for TDD plan generation - * - * Combines what was previously 5 separate agents: - * - Requirements Analyst (redundant) - * - Architecture Planner (redundant) - * - Testing Specialist (kept - TDD focus) - * - Risk Analyst (redundant) - * - Verification Expert (kept - structure) + * @fileoverview Planner Prompt — single TDD plan generator combining + * requirements analysis, architecture, testing, risk, and verification + * into one agent (previously 5 separate agents). * * Placeholders: {TASK}, {RESEARCH_CONTEXT} + * + * @dependencies none (pure template) + * @consumedby prompts/index (re-export), plan-orchestrator + * @module prompts/planner */ export const PLANNER_PROMPT = `You are a TDD Plan Generator. Create a complete implementation plan with test-first approach. diff --git a/src/prompts/research-agent.ts b/src/prompts/research-agent.ts index 9429a2df..5fb60d64 100644 --- a/src/prompts/research-agent.ts +++ b/src/prompts/research-agent.ts @@ -1,10 +1,12 @@ /** - * Research Agent Prompt - * - * Gathers external resources, codebase patterns, and technical context - * before other agents analyze the task. + * @fileoverview Research Agent Prompt — gathers codebase patterns, external + * resources, and technical context before the planner analyzes the task. * * Placeholders: {TASK}, {WORKING_DIR} + * + * @dependencies none (pure template) + * @consumedby prompts/index (re-export), plan-orchestrator + * @module prompts/research-agent */ export const RESEARCH_AGENT_PROMPT = `You are a Research Specialist preparing context for an implementation task. Your job is to gather all relevant information that will help the development team succeed. diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 08d75a24..08e49b3b 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -54,6 +54,7 @@ import { todoContentHash, stringSimilarity, Debouncer, + CleanupManager, } from './utils/index.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; @@ -76,12 +77,19 @@ export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js const TODO_EXPIRY_MS = 60 * 60 * 1000; /** - * Minimum interval between cleanup checks (in milliseconds). + * Minimum interval between on-demand cleanup checks (in milliseconds). * Prevents running cleanup on every data chunk. * Default: 30 seconds */ const CLEANUP_THROTTLE_MS = 30 * 1000; +/** + * Interval for periodic todo expiry cleanup (in milliseconds). + * Actively purges expired todos even when no terminal data is flowing. + * Default: 5 minutes + */ +const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; + /** * Similarity threshold for todo deduplication. * Todos with similarity >= this value are considered duplicates. @@ -517,6 +525,9 @@ export class RalphTracker extends EventEmitter { /** Last calculated completion confidence */ private _lastCompletionConfidence: CompletionConfidence | undefined; + /** Manages periodic cleanup timers (todo expiry) */ + private cleanup = new CleanupManager(); + /** Confidence threshold for triggering completion (0-100) */ private static readonly COMPLETION_CONFIDENCE_THRESHOLD = 70; @@ -536,6 +547,12 @@ export class RalphTracker extends EventEmitter { // Wire sub-module events this._wireSubModuleEvents(); + + // Periodic cleanup of expired todos — ensures stale entries are purged + // even when no terminal data is flowing (e.g., idle sessions) + this.cleanup.setInterval(() => this.cleanupExpiredTodos(), TODO_CLEANUP_INTERVAL_MS, { + description: 'ralph todo expiry cleanup', + }); } /** @@ -2204,6 +2221,7 @@ export class RalphTracker extends EventEmitter { if (toDelete.length > 0) { for (const id of toDelete) { this._todos.delete(id); + this._todoStartTimes.delete(id); } this.emit('todoUpdate', this.todos); } @@ -2348,6 +2366,7 @@ export class RalphTracker extends EventEmitter { * Clean up all resources and release memory. */ destroy(): void { + this.cleanup.dispose(); this._todoDeb.dispose(); this._loopDeb.dispose(); this.fixPlanWatcher.destroy(); diff --git a/src/web/public/app.js b/src/web/public/app.js index eedbe9d0..5f226a6d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -196,6 +196,7 @@ const _SSE_HANDLER_MAP = [ class CodemanApp { constructor() { this.sessions = new Map(); + this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID this.cases = []; @@ -1558,16 +1559,25 @@ class CodemanApp { this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay); }; + // Create stable handler wrappers once (reused across reconnects so + // removeEventListener always matches the original reference) + if (!this._sseHandlerWrappers) { + this._sseHandlerWrappers = new Map(); + for (const [event, method] of _SSE_HANDLER_MAP) { + const fn = this[method]; + this._sseHandlerWrappers.set(event, (e) => { + try { + fn.call(this, e.data ? JSON.parse(e.data) : {}); + } catch (err) { + console.error(`[SSE] Error handling ${event}:`, err); + } + }); + } + } + // Register all SSE event handlers via centralized map - for (const [event, method] of _SSE_HANDLER_MAP) { - const fn = this[method]; - addListener(event, (e) => { - try { - fn.call(this, e.data ? JSON.parse(e.data) : {}); - } catch (err) { - console.error(`[SSE] Error handling ${event}:`, err); - } - }); + for (const [event] of _SSE_HANDLER_MAP) { + addListener(event, this._sseHandlerWrappers.get(event)); } } @@ -1711,7 +1721,7 @@ class CodemanApp { urgency: 'critical', category: 'session-error', sessionId: data.id, - sessionName: session?.name || data.id?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.id), title: 'Session Error', message: data.error || 'Unknown error', }); @@ -1730,7 +1740,7 @@ class CodemanApp { urgency: 'critical', category: 'session-crash', sessionId: data.id, - sessionName: session?.name || data.id?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.id), title: 'Session Crashed', message: `Exited with code ${data.code}`, }); @@ -1755,7 +1765,7 @@ class CodemanApp { urgency: 'warning', category: 'session-stuck', sessionId: data.id, - sessionName: s?.name || data.id?.slice(0, 8), + sessionName: s?.name || this.getShortId(data.id), title: 'Session Idle', message: `Idle for ${Math.round(threshold / 60000)}+ minutes`, }); @@ -1794,7 +1804,7 @@ class CodemanApp { urgency: 'info', category: 'auto-clear', sessionId: data.sessionId, - sessionName: session?.name || data.sessionId?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.sessionId), title: 'Auto-Cleared', message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`, }); @@ -1880,7 +1890,7 @@ class CodemanApp { urgency: 'critical', category: 'respawn-blocked', sessionId: data.sessionId, - sessionName: session?.name || data.sessionId?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.sessionId), title, message: data.details, }); @@ -1900,7 +1910,7 @@ class CodemanApp { urgency: 'info', category: 'auto-accept', sessionId: data.sessionId, - sessionName: session?.name || data.sessionId?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.sessionId), title: 'Plan Accepted', message: `Accepted plan mode for ${session?.name || 'session'}`, }); @@ -2031,7 +2041,7 @@ class CodemanApp { _onMuxDied(data) { this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId); this.renderMuxSessions(); - this.showToast('Mux session died: ' + data.sessionId.slice(0, 8), 'warning'); + this.showToast('Mux session died: ' + this.getShortId(data.sessionId), 'warning'); } _onMuxStatsUpdated(data) { @@ -2081,7 +2091,7 @@ class CodemanApp { urgency: 'warning', category: 'ralph-complete', sessionId: data.sessionId, - sessionName: session?.name || data.sessionId?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.sessionId), title: 'Loop Complete', message: `Completion: ${data.phrase || 'unknown'}`, }); @@ -2104,7 +2114,7 @@ class CodemanApp { urgency: 'critical', category: 'circuit-breaker', sessionId: data.sessionId, - sessionName: session?.name || data.sessionId?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.sessionId), title: 'Circuit Breaker Open', message: data.status.reason || 'Loop stuck - no progress detected', }); @@ -2117,7 +2127,7 @@ class CodemanApp { urgency: 'warning', category: 'exit-gate', sessionId: data.sessionId, - sessionName: session?.name || data.sessionId?.slice(0, 8), + sessionName: session?.name || this.getShortId(data.sessionId), title: 'Exit Gate Met', message: `Loop ready to exit (indicators: ${data.completionIndicators})`, }); @@ -3273,6 +3283,16 @@ class CodemanApp { // Session Lifecycle — select, close, navigate // ═══════════════════════════════════════════════════════════════ + getShortId(id) { + if (!id) return ''; + let short = this._shortIdCache.get(id); + if (!short) { + short = id.slice(0, 8); + this._shortIdCache.set(id, short); + } + return short; + } + getSessionName(session) { // Use custom name if set if (session.name) { @@ -3282,7 +3302,7 @@ class CodemanApp { if (session.workingDir) { return session.workingDir.split('/').pop() || session.workingDir; } - return session.id.slice(0, 8); + return this.getShortId(session.id); } async selectSession(sessionId) { @@ -6619,7 +6639,7 @@ class CodemanApp { tbody.innerHTML = data.entries.map(e => { const time = new Date(e.ts).toLocaleString(); const color = eventColors[e.event] || '#888'; - const name = e.name || (e.sessionId === '*' ? '—' : e.sessionId.slice(0, 8)); + const name = e.name || (e.sessionId === '*' ? '—' : this.getShortId(e.sessionId)); const extra = []; if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`); if (e.mode) extra.push(e.mode); @@ -9386,7 +9406,7 @@ class CodemanApp { if (windowData.resizeObserver) { windowData.resizeObserver.disconnect(); } - // Clean up global drag event listeners (prevents memory leak) + // Clean up drag event listeners (both document-level and handle-level) if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); @@ -9395,6 +9415,11 @@ class CodemanApp { document.removeEventListener('touchend', windowData.dragListeners.up); document.removeEventListener('touchcancel', windowData.dragListeners.up); } + // Remove handle-level listeners before DOM removal + if (windowData.dragListeners.handle) { + windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown); + windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart); + } } windowData.element.remove(); this.subagentWindows.delete(agentId); @@ -9986,6 +10011,10 @@ class CodemanApp { document.removeEventListener('touchend', this.teamTasksDragListeners.up); document.removeEventListener('touchcancel', this.teamTasksDragListeners.up); } + if (this.teamTasksDragListeners.handle) { + this.teamTasksDragListeners.handle.removeEventListener('mousedown', this.teamTasksDragListeners.handleMouseDown); + this.teamTasksDragListeners.handle.removeEventListener('touchstart', this.teamTasksDragListeners.handleTouchStart); + } this.teamTasksDragListeners = null; } } @@ -10743,10 +10772,14 @@ class CodemanApp { windowData.eventSource.close(); } - // Clean up global drag event listeners (prevents memory leak) + // Clean up drag event listeners (both document-level and handle-level) if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); + if (windowData.dragListeners.handle) { + windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown); + windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart); + } } // Remove element @@ -10870,7 +10903,7 @@ class CodemanApp { const popupData = this.imagePopups.get(imageId); if (!popupData) return; - // Clean up global drag event listeners + // Clean up drag event listeners (both document-level and handle-level) if (popupData.dragListeners) { document.removeEventListener('mousemove', popupData.dragListeners.move); document.removeEventListener('mouseup', popupData.dragListeners.up); @@ -10879,6 +10912,10 @@ class CodemanApp { document.removeEventListener('touchend', popupData.dragListeners.up); document.removeEventListener('touchcancel', popupData.dragListeners.up); } + if (popupData.dragListeners.handle) { + popupData.dragListeners.handle.removeEventListener('mousedown', popupData.dragListeners.handleMouseDown); + popupData.dragListeners.handle.removeEventListener('touchstart', popupData.dragListeners.handleTouchStart); + } } // Remove element diff --git a/src/web/public/subagent-windows.js b/src/web/public/subagent-windows.js index 8c5a312b..546da8d6 100644 --- a/src/web/public/subagent-windows.js +++ b/src/web/public/subagent-windows.js @@ -1012,19 +1012,20 @@ Object.assign(CodemanApp.prototype, { } }; - // Mouse events - handle.addEventListener('mousedown', (e) => { + // Named handle-level listeners (stored for explicit cleanup on window close) + const handleMouseDown = (e) => { if (e.target.tagName === 'BUTTON') return; startDrag(e.clientX, e.clientY); e.preventDefault(); - }); - - // Touch events - handle.addEventListener('touchstart', (e) => { + }; + const handleTouchStart = (e) => { if (e.target.tagName === 'BUTTON') return; const touch = e.touches[0]; startDrag(touch.clientX, touch.clientY); - }, { passive: true }); + }; + + handle.addEventListener('mousedown', handleMouseDown); + handle.addEventListener('touchstart', handleTouchStart, { passive: true }); // Store references to document-level listeners so they can be removed on window close const moveListener = (e) => { @@ -1048,8 +1049,15 @@ Object.assign(CodemanApp.prototype, { document.addEventListener('touchend', upListener); document.addEventListener('touchcancel', upListener); - // Return listener references for cleanup - return { move: moveListener, up: upListener, touchMove: touchMoveListener }; + // Return all listener references for cleanup (both handle-level and document-level) + return { + move: moveListener, + up: upListener, + touchMove: touchMoveListener, + handle, + handleMouseDown, + handleTouchStart, + }; }, // Show subagent dropdown on hover diff --git a/src/web/server.ts b/src/web/server.ts index 20a29af2..3f574d61 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -270,6 +270,8 @@ export class WebServer extends EventEmitter { error: (error: Error, sessionId?: string) => void; } | null = null; private tunnelManager: TunnelManager = new TunnelManager(); + /** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */ + private _isTunnelActive: boolean = false; private authSessions: StaleExpirationMap | null = null; private authFailures: StaleExpirationMap | null = null; private qrAuthFailures: StaleExpirationMap | null = null; @@ -326,9 +328,11 @@ export class WebServer extends EventEmitter { // Set up tunnel manager listeners this.tunnelManager.on('started', (data: { url: string }) => { + this._isTunnelActive = true; this.broadcast(SseEvent.TunnelStarted, data); }); this.tunnelManager.on('stopped', () => { + this._isTunnelActive = false; this.broadcast(SseEvent.TunnelStopped, {}); }); this.tunnelManager.on('error', (message: string) => { @@ -601,7 +605,7 @@ export class WebServer extends EventEmitter { this.sendSSE(reply, SseEvent.Init, this.getLightState()); // Flush Cloudflare tunnel buffer with padding — ensures the init event // (and any immediately following events) are delivered without proxy delay. - if (this.tunnelManager.getUrl()) { + if (this._isTunnelActive) { try { reply.raw.write(SSE_PADDING); } catch { @@ -1931,21 +1935,22 @@ export class WebServer extends EventEmitter { } private broadcast(event: string, data: unknown): void { - // Invalidate caches only on structurally significant events — ones that - // change session list content (creation, deletion, or full state refresh). - // High-frequency non-structural events (working/idle transitions, completion, - // error, respawn state changes) are NOT worth invalidating for because: - // 1. The debounced session:updated follows within 500ms with the new state - // 2. These caches serve /api/sessions and SSE init — neither is polled rapidly - // 3. Invalidating on every working/idle transition makes the 1s TTL useless - if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted || event === SseEvent.SessionUpdated) { + // Skip serialization entirely when no clients are listening + if (this.sseClients.size === 0) return; + + // Invalidate caches only on structural changes (creation/deletion). + // SessionUpdated fires too frequently (working/idle transitions, completion) + // and makes the 1s TTL cache useless — the debounced session:updated follows + // within 500ms anyway, and these caches serve /api/sessions and SSE init + // which aren't polled rapidly. + if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted) { this.cachedLightState = null; this.cachedSessionsList = null; } // Performance optimization: serialize JSON once for all clients. // Only append Cloudflare tunnel padding when tunnel is actually active — // direct/Tailscale clients don't need 8KB padding on every event. - const padding = this.tunnelManager.getUrl() ? SSE_PADDING : ''; + const padding = this._isTunnelActive ? SSE_PADDING : ''; let message: string; try { message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding; @@ -2221,7 +2226,7 @@ export class WebServer extends EventEmitter { // Send SSE comment as keep-alive. Only add padding when tunnel is // active — it flushes Cloudflare proxy buffers but wastes bandwidth // for direct/Tailscale connections. - const ka = this.tunnelManager.getUrl() ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n'; + const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n'; client.raw.write(ka); } } catch {