diff --git a/.gitignore b/.gitignore index 1fdfe5c0..832800f1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ media-assets/ commands todo.md @fix_plan.md +readme-preview.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 512cd5cd..9d9bb3fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,39 +64,23 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Requirements**: Node.js 18+, Claude CLI, tmux -## Commands +**Git**: Main branch is `master`. SSH session chooser: `sc` (interactive), `sc 2` (quick attach), `sc -l` (list). -**Note**: `npm run dev` starts the web server (equivalent to `npx tsx src/index.ts web`). +## Additional Commands -**Default port**: `3000` (web UI at `http://localhost:3000`) +`npm run dev` = dev server. Default port: `3000`. Commands not in Quick Reference: -```bash -# Setup -npm install # Install dependencies +| Task | Command | +|------|---------| +| Dev with TLS | `npx tsx src/index.ts web --https` | +| Continuous typecheck | `tsc --noEmit --watch` | +| Test coverage | `npm run test:coverage` | +| Production start | `npm run start` | +| Production logs | `journalctl --user -u codeman-web -f` | -# Development -npx tsx src/index.ts web # Dev server (RECOMMENDED) -npx tsx src/index.ts web --https # With TLS (only needed for remote access) -npm run typecheck # Type check -tsc --noEmit --watch # Continuous type checking -npm run lint # ESLint -npm run lint:fix # ESLint with auto-fix -npm run format # Prettier format -npm run format:check # Prettier check only +**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master (Node 22). Tests excluded (they spawn tmux). -# Testing (see "Testing" section for CRITICAL safety warnings) -npx vitest run test/.test.ts # Single file (SAFE) -npx vitest run -t "pattern" # Tests matching name -npm run test:coverage # With coverage report - -# Production -npm run build # esbuild via scripts/build.mjs (not tsc) -npm run start # node dist/index.js (production) -systemctl --user restart codeman-web -journalctl --user -u codeman-web -f -``` - -**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, and `format:check` on push to master. Tests are intentionally excluded from CI (they spawn tmux). +**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Does not lint `app.js` or `scripts/**/*.mjs`. ## Common Gotchas @@ -106,6 +90,8 @@ journalctl --user -u codeman-web -f - **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` ## Import Conventions @@ -115,103 +101,32 @@ journalctl --user -u codeman-web -f ## Architecture -### Core Files +### Core Files (by domain) -| File | Purpose | -|------|---------| -| `src/index.ts` | CLI entry point: global error recovery, uncaught exception guard, `MAX_CONSECUTIVE_ERRORS` auto-restart | -| `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` | -| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type | -| `src/mux-factory.ts` | Create tmux multiplexer instance | -| `src/tmux-manager.ts` | tmux session management | -| `src/session-manager.ts` | Session lifecycle, cleanup | -| `src/session-auto-ops.ts` | Automatic session operations (auto-compact, etc.) | -| `src/session-cli-builder.ts` | CLI argument construction for session spawning | -| `src/session-task-cache.ts` | Task description caching for subagent correlation | -| `src/state-store.ts` | State persistence to `~/.codeman/state.json` | -| `src/respawn-controller.ts` | State machine for autonomous cycling | -| `src/respawn-adaptive-timing.ts` | Adaptive idle timing calculation | -| `src/respawn-health.ts` | Health scoring (0-100) for respawn loops | -| `src/respawn-metrics.ts` | Per-cycle outcome metrics tracking | -| `src/respawn-patterns.ts` | Pattern matching for stuck/error states | -| `src/ralph-tracker.ts` | Detects `PHRASE`, todos | -| `src/ralph-loop.ts` | Autonomous task execution loop (polls queue, assigns tasks) | -| `src/ralph-config.ts` | Parses `.claude/ralph-loop.local.md` plugin config | -| `src/ralph-fix-plan-watcher.ts` | Watches `@fix_plan.md` for changes | -| `src/ralph-plan-tracker.ts` | Plan iteration tracking | -| `src/ralph-stall-detector.ts` | Detects stuck Ralph loops | -| `src/ralph-status-parser.ts` | Parses Ralph status messages | -| `src/task.ts` | Task model for prompt execution | -| `src/task-queue.ts` | Priority queue for tasks with dependencies | -| `src/task-tracker.ts` | Background task tracker for subagent detection | -| `src/subagent-watcher.ts` | Monitors Claude Code's Task tool (background agents) | -| `src/team-watcher.ts` | Polls `~/.claude/teams/` for agent team activity; matches teams to sessions via `leadSessionId` | -| `src/run-summary.ts` | Timeline events for "what happened while away" | -| `src/ai-checker-base.ts` | Base class for AI-powered checkers (shared by idle + plan checkers) | -| `src/ai-idle-checker.ts` | AI-powered idle detection | -| `src/ai-plan-checker.ts` | AI-powered plan completion checker | -| `src/bash-tool-parser.ts` | Parses Claude's bash tool invocations from output | -| `src/transcript-watcher.ts` | Watches Claude's transcript files for changes | -| `src/hooks-config.ts` | Manages `.claude/settings.local.json` hook configuration | -| `src/push-store.ts` | VAPID key auto-gen + push subscription CRUD for Web Push | -| `src/session-lifecycle-log.ts` | Append-only JSONL audit log at `~/.codeman/session-lifecycle.jsonl` | -| `src/image-watcher.ts` | Watches for image file creation (screenshots, etc.) | -| `src/file-stream-manager.ts` | Manages `tail -f` processes for live log viewing | -| `src/plan-orchestrator.ts` | 2-agent plan generation: optional research agent → planner agent | -| `src/prompts/index.ts` | Barrel export for all agent prompts | -| `src/prompts/*.ts` | Agent prompts (research-agent, planner) | -| `src/templates/claude-md.ts` | CLAUDE.md generation for new cases | -| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel + QR auth token rotation | -| `src/cli.ts` | Command-line interface handlers | -| `src/web/server.ts` | Fastify server setup, SSE at `/api/events`, delegates to route modules | -| `src/web/routes/*.ts` | 12 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` | -| `src/web/ports/*.ts` | Port interfaces (SessionPort, EventPort, etc.) — route modules declare dependencies via intersection types | -| `src/web/middleware/auth.ts` | Auth middleware: Basic Auth, session cookies, rate limiting, security headers, CORS | -| `src/web/route-helpers.ts` | Shared helper utilities for route modules | -| `src/web/schemas.ts` | Zod v4 validation schemas with path/env security allowlists | -| `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows, mobile support (~12K lines) | -| `src/types.ts` | Barrel re-export from `src/types/` — 13 domain files (session, task, respawn, ralph, api, etc.) | +| 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 | -**Large files** (>50KB): `app.js`, `ralph-tracker.ts`, `respawn-controller.ts`, `session.ts`, `subagent-watcher.ts` — these contain complex state machines; read `docs/respawn-state-machine.md` before modifying. +★ = Large file (>50KB), contains complex state machines. Read `docs/respawn-state-machine.md` before modifying respawn/ralph. -### Local Packages +**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). -| Package | Purpose | -|---------|---------| -| `packages/xterm-zerolag-input/` | Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections. Source of truth for `LocalEchoOverlay`; a copy is embedded in `app.js`. Build: `npm run build` (tsup). | +**Config**: `src/config/` — 9 files (buffer limits, map limits, timeouts, SSE timing, auth, tunnel, terminal, AI, teams). Import from specific files. -### Config Files (`src/config/`) - -| File | Purpose | -|------|---------| -| `buffer-limits.ts` | Terminal/text buffer size limits | -| `map-limits.ts` | Global limits for Maps, sessions, watchers | -| `exec-timeout.ts` | Execution timeout configuration | -| `server-timing.ts` | Web server batching, SSE, scheduled run timing | -| `auth-config.ts` | Auth session TTL, rate limits, hook timeout | -| `tunnel-config.ts` | QR token rotation, tunnel process lifecycle | -| `terminal-limits.ts` | Terminal dimension and input validation limits | -| `ai-defaults.ts` | AI checker model and context limits | -| `team-config.ts` | Agent Teams polling and cache sizes | - -### Utilities (`src/utils/`) - -Re-exported via `src/utils/index.ts`. Key exports: - -| File | Exports | -|------|---------| -| `cleanup-manager.ts` | `CleanupManager` — centralized disposal for timers, intervals, watchers, listeners, streams | -| `lru-map.ts` | `LRUMap` — bounded cache with eviction | -| `stale-expiration-map.ts` | `StaleExpirationMap` — TTL-based map with automatic cleanup | -| `regex-patterns.ts` | `ANSI_ESCAPE_PATTERN_FULL/SIMPLE`, `createAnsiPatternFull/Simple()`, `stripAnsi`, `TOKEN_PATTERN`, `SPINNER_PATTERN` | -| `buffer-accumulator.ts` | `BufferAccumulator` — batches rapid writes into single flushes | -| `claude-cli-resolver.ts` | `findClaudeDir`, `getAugmentedPath` — resolves Claude CLI paths | -| `opencode-cli-resolver.ts` | `resolveOpenCodeDir`, `isOpenCodeAvailable` — OpenCode CLI support | -| `string-similarity.ts` | `stringSimilarity`, `fuzzyPhraseMatch`, `todoContentHash` | -| `token-validation.ts` | `validateTokenCounts`, `validateTokensAndCost` | -| `nice-wrapper.ts` | `wrapWithNice` — wraps commands with `nice`/`ionice` for lower priority | -| `type-safety.ts` | `assertNever` — exhaustive switch/case guard | -| `debouncer.ts` | `Debouncer` — reusable debounce utility | +**Utilities**: `src/utils/` — re-exported via `src/utils/index.ts`. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `createAnsiPatternFull/Simple()`, `assertNever`, `Debouncer`. ### Data Flow @@ -222,133 +137,74 @@ Re-exported via `src/utils/index.ts`. Key exports: ### Key Patterns -**Input to sessions**: Use `session.writeViaMux()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line. - -**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend. +**Input**: `session.writeViaMux()` for programmatic input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only. **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. -**Token tracking**: Interactive mode parses status line ("123.4k tokens"), estimates 60/40 input/output split. +**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`. -**Hook events**: Claude Code hooks trigger notifications via `/api/hook-event`. Key events: `permission_prompt` (tool approval needed), `elicitation_dialog` (Claude asking question), `idle_prompt` (waiting for input), `stop` (response complete), `teammate_idle` (Agent Teams), `task_completed` (Agent Teams). See `src/hooks-config.ts`. +**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `agent-teams/`. -**Web Push**: Layer 5 of the notification system. Service worker (`sw.js`) receives push events and shows OS-level notifications even when the browser tab is closed. VAPID keys auto-generated on first use and persisted to `~/.codeman/push-keys.json`. Per-subscription per-event preferences stored in `~/.codeman/push-subscriptions.json`. Expired subscriptions (410/404) auto-cleaned. Requires HTTPS or localhost. iOS requires PWA installed to home screen. See `src/push-store.ts`. +**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. -**Agent Teams (experimental)**: `TeamWatcher` polls `~/.claude/teams/` for team configs and matches teams to sessions via `leadSessionId`. Teammates are in-process threads (not separate OS processes) and appear as standard subagents. RespawnController checks `TeamWatcher.hasActiveTeammates()` before triggering respawn. Enable via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var in `settings.local.json`. See `agent-teams/` for full docs. +**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`). -**Circuit breaker**: Prevents respawn thrashing when Claude is stuck. States: `CLOSED` (normal) → `HALF_OPEN` (testing) → `OPEN` (blocked). Tracks consecutive no-progress, same-error-repeated, and tests-failing-too-long. Reset via API at `/api/sessions/:id/ralph-circuit-breaker/reset`. +### Frontend -**Respawn cycle metrics & health scoring**: `RespawnCycleMetrics` tracks per-cycle outcomes (success, stuck_recovery, blocked, error). `RalphLoopHealthScore` computes 0-100 health with component scores (cycleSuccess, circuitBreaker, iterationProgress, aiChecker, stuckRecovery). Available via respawn status API. - -**Subagent-session correlation**: Session parses Task tool output via `BashToolParser` → `SubagentWatcher` discovers new agent → calls `session.findTaskDescriptionNear()` to match description for window title. - -**Port interfaces**: Route modules declare their dependencies via port interfaces (`src/web/ports/`). `WebServer` implements all ports; routes use TypeScript intersection types (e.g., `SessionPort & EventPort`) to specify only what they need. This enables loose coupling between routes and the server. - -### Frontend Files - -| File | Purpose | -|------|---------| -| `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading | -| `src/web/public/constants.js` | Shared constants, timing values, Z-index layers, Web Push utilities | -| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`, `_apiPut`) | -| `src/web/public/mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` objects | -| `src/web/public/voice-input.js` | `DeepgramProvider`, `VoiceInput` objects for speech-to-text | -| `src/web/public/notification-manager.js` | `NotificationManager` class (5-layer notification system) | -| `src/web/public/keyboard-accessory.js` | `KeyboardAccessoryBar` and `FocusTrap` classes | -| `src/web/public/subagent-windows.js` | Subagent window management (open, close, drag, connection lines) | -| `src/web/public/app.js` | Core UI: xterm.js, tab management, settings | -| `src/web/public/ralph-wizard.js` | Ralph Loop wizard UI | -| `src/web/public/styles.css` | Main styling (dark theme, layout, components) | -| `src/web/public/mobile.css` | Responsive overrides for screens <1024px (loaded conditionally via `media` attribute) | -| `src/web/public/upload.html` | Screenshot upload page served at `/upload.html` | -| `src/web/public/sw.js` | Service worker for Web Push notifications | -| `src/web/public/manifest.json` | Minimal PWA manifest (required for push on Android) | -| `src/web/public/vendor/` | Self-hosted xterm.js + addons (eliminates CDN latency) | - -**Script loading order** (index.html): `constants.js` → `mobile-handlers.js` → `voice-input.js` → `notification-manager.js` → `keyboard-accessory.js` → `app.js` → `ralph-wizard.js` → `api-client.js` → `subagent-windows.js`. All modules share global scope — order matters for dependencies. - -### Frontend Architecture - -The frontend is split across multiple vanilla JS modules (extracted from the original monolithic `app.js`). Key systems: - -| System | Module | Key Classes/Functions | Purpose | -|--------|--------|----------------------|---------| -| **Terminal rendering** | `app.js` | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()` | 60fps batched writes with DEC 2026 sync | -| **Local echo overlay** | `app.js` | `LocalEchoOverlay` class | DOM overlay for instant mobile keystroke feedback | -| **Mobile support** | `mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` | Touch input, viewport adaptation, swipe navigation | -| **Keyboard accessory** | `keyboard-accessory.js` | `KeyboardAccessoryBar`, `FocusTrap` | Mobile keyboard toolbar, modal focus management | -| **Subagent windows** | `subagent-windows.js` | `openSubagentWindow()`, `closeSubagentWindow()`, `updateConnectionLines()` | Floating terminal windows with parent connection lines | -| **Notifications** | `notification-manager.js` | `NotificationManager` class | 5-layer: in-app drawer, tab flash, browser API, web push, audio beep | -| **Voice input** | `voice-input.js` | `DeepgramProvider`, `VoiceInput` | Speech-to-text via Deepgram WebSocket | -| **SSE connection** | `app.js` | `connectSSE()`, `addListener()` | EventSource with exponential backoff (1-30s), offline queue (64KB) | -| **Settings** | `app.js` | `openAppSettings()`, `apply*Visibility()` | Server-backed + localStorage persistence | +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `app.js`(6) → `ralph-wizard.js`(7) → `api-client.js`(8) → `subagent-windows.js`(9). **Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7). -**Built-in respawn presets**: `solo-work` (3s idle, 60min), `subagent-workflow` (45s idle, 240min), `team-lead` (90s idle, 480min), `ralph-todo` (8s idle, 480min, works through @fix_plan.md tasks), `overnight-autonomous` (10s idle, 480min, full reset). +**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). -**Keyboard shortcuts**: Escape (close panels), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill session), Ctrl+Tab (next session), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font size). +**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font). ### Security -- **HTTP Basic Auth**: Optional via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars -- **QR Auth**: Single-use ephemeral 6-char tokens (60s TTL, 90s grace) for tunnel login without typing passwords. `TunnelManager` rotates tokens, serves cached SVG at `GET /api/tunnel/qr`, validates at `GET /q/:code`. Separate per-IP rate limit (10/15min) + global path limit (30/min). Desktop notification on consumption (QRLjacking detection). Audit logged as `qr_auth` in `session-lifecycle.jsonl`. See `docs/qr-auth-plan.md`. -- **Session cookies**: After Basic Auth or QR Auth, a 24h session cookie (`codeman_session`) is issued so credentials aren't re-sent on every request. Active sessions auto-extend. SSE works via same-origin cookie (`EventSource` can't send custom headers). Sessions store device context (IP + User-Agent) for audit via `AuthSessionRecord`. -- **Session revocation**: `POST /api/auth/revoke` revokes individual sessions or all sessions. -- **Rate limiting**: 10 failed auth attempts per IP triggers 429 rejection (15-minute decay window). Manual `StaleExpirationMap` counter — no `@fastify/rate-limit` needed. QR auth has its own separate rate limiter. -- **Hook bypass**: `/api/hook-event` POST is exempt from auth — Claude Code hooks curl this from localhost and can't present credentials. Safe: validated by `HookEventSchema`, only triggers broadcasts. -- **CORS**: Restricted to localhost only -- **Security headers**: X-Content-Type-Options, X-Frame-Options, CSP; HSTS if HTTPS -- **Path validation** (`schemas.ts`): Strict allowlist regex, no shell metacharacters, no traversal, must be absolute -- **Env var allowlist**: Only `CLAUDE_CODE_*` prefixes allowed; blocks `PATH`, `LD_PRELOAD`, `NODE_OPTIONS`, `CODEMAN_*` keys -- **File streaming TOCTOU protection**: `FileStreamManager` calls `realpathSync()` twice (at validation and before spawn) to catch symlink swaps +| Layer | Details | +|-------|---------| +| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars | +| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | +| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | +| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter | +| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) | +| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks) | +| **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist | +| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | -### SSE Event Categories +### SSE Event Registry -~100 event types broadcast via `broadcast()`. Key categories: - -| Category | Events | Purpose | -|----------|--------|---------| -| Session | `session:created/updated/deleted/working/idle/exit/error/completion` | Lifecycle | -| Terminal | `session:terminal`, `session:clearTerminal`, `session:needsRefresh` | Output streaming | -| Respawn | `respawn:stateChanged/cycleStarted/blocked/aiCheck*/planCheck*/timer*` | Respawn state machine | -| Subagent | `subagent:discovered/updated/completed/tool_call/progress` | Background agents | -| Ralph | `session:ralphLoopUpdate/ralphTodoUpdate/ralphCompletionDetected` | Ralph tracking | -| Hooks | `hook:{eventName}` (dynamic) | Claude Code hook events | -| Plan | `plan:started/progress/completed/cancelled/subagent` | Plan orchestration | -| Mux | `mux:created/killed/died/statsUpdated` | tmux process monitor | -| Tunnel | `tunnel:qrRotated/qrRegenerated/qrAuthUsed` | QR token lifecycle | -| Image | `image:detected` | Screenshot detection | +~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 -~113 route handlers split across `src/web/routes/` domain modules. Key groups: +~111 route handlers in `src/web/routes/`. Key groups: | Group | Prefix | Count | Key endpoints | |-------|--------|-------|---------------| -| Sessions | `/api/sessions` | 43 | CRUD, input, resize, interactive, shell | -| System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings`, `/api/subagents` | 38 | App state, config, subagents | -| Ralph | `/api/sessions/:id/ralph-*` | 19 | state, status, config, circuit-breaker | -| Respawn | `/api/sessions/:id/respawn` | 17 | start, stop, enable, config | -| Plan | `/api/sessions/:id/plan/*` | 12 | task CRUD, checkpoint, history, rollback | +| 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 | -| Scheduled | `/api/scheduled` | 6 | CRUD for scheduled runs | | 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 | -| Hooks | `/api/hook-event` | 4 | Hook event ingestion | | Teams | `/api/teams` | 2 | list teams, get team tasks | +| Hooks | `/api/hook-event` | 1 | Hook event ingestion | ## Adding Features -- **API endpoint**: Types in `src/types/` (domain file), route in the appropriate `src/web/routes/*-routes.ts` module, use `createErrorResponse()`. Validate request bodies with Zod schemas in `schemas.ts`. -- **SSE event**: Emit via `broadcast()`, handle in `app.js` SSE listener section (search `addListener(`) -- **Session setting**: Add to `SessionState` in `types.ts`, include in `session.toState()`, call `persistSessionState()` -- **Hook event**: Add to `HookEventType` in `types.ts`, add hook command in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` in `schemas.ts` -- **Mobile feature**: Add to relevant mobile singleton (`KeyboardHandler`, `KeyboardAccessoryBar`, etc.), test with `MobileDetection.isMobile()` guard -- **New test**: Pick unique port (search `const PORT =`), add port comment to test file header. Tests use ports 3150+. +- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`, use `createErrorResponse()`. Validate with Zod schemas in `schemas.ts`. +- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`) +- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()` +- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` +- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()` +- **New test**: Pick unique port (search `const PORT =`). Integration: ports 3099-3211. Route tests: `app.inject()` — see `test/routes/_route-test-utils.ts`. -**Validation**: Uses Zod v4 for request validation. Define schemas in `schemas.ts` and use `.parse()` or `.safeParse()`. Note: Zod v4 has different API from v3 (e.g., `z.object()` options changed, error formatting differs). +**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`. ## State Files @@ -358,239 +214,96 @@ The frontend is split across multiple vanilla JS modules (extracted from the ori | `~/.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` | Registered push notification subscriptions | +| `~/.codeman/push-subscriptions.json` | Push notification subscriptions | +| `~/.codeman/session-lifecycle.jsonl` | Append-only audit log (QR auth, session events) | ## Default Settings -UI defaults are set in `src/web/public/app.js` using `??` fallbacks. To change defaults, edit `openAppSettings()` and `apply*Visibility()` functions. - -**Key defaults:** Most panels hidden (monitor, subagents shown), notifications enabled (audio disabled), subagent tracking on, Ralph tracking off. +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. ## Testing -**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Instead: +**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files: ```bash -# Safe: run individual test files -npx vitest run test/.test.ts - -# Safe: run tests matching a pattern -npx vitest run -t "pattern" - -# DANGEROUS from inside Codeman — will kill your tmux session: -# npx vitest run ← DON'T DO THIS +npx vitest run test/.test.ts # Single file (SAFE) +npx vitest run -t "pattern" # By name (SAFE) +# npx vitest run # DANGEROUS — DON'T DO THIS ``` -**Ports**: Unit tests pick unique ports manually. Search `const PORT =` before adding new tests. +**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. -**Config**: Vitest with `globals: true`, `fileParallelism: false`. Unit timeout 30s. +**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up. -**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions at load time and never kills them. Only sessions registered via `registerTestTmuxSession()` get cleaned up. +**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests. -**Respawn tests**: Use MockSession from `test/respawn-test-utils.ts` to avoid spawning real Claude processes. +**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `mobile-test/` (135 device profiles). -**Mobile tests**: Separate Playwright-based suite in `mobile-test/` with 135 device profiles. Run via `npx vitest run --config mobile-test/vitest.config.ts`. See `mobile-test/README.md`. +## Screenshots -## Screenshots ("sc") +"sc"/"screenshot" = uploaded mobile screenshots in `~/.codeman/screenshots/`. View with Read tool. API: `GET /api/screenshots` (list), `POST /api/screenshots` (upload). -When the user says "check the sc", "screenshot", or "sc", they mean uploaded screenshots from their mobile device. Screenshots are saved to `~/.codeman/screenshots/` and uploaded via `/upload.html` on the Codeman web UI. To view them, use the Read tool on the image files: +## Debugging & Troubleshooting ```bash -ls ~/.codeman/screenshots/ # List uploaded screenshots -# Then use Read tool on individual files — Claude Code can view images natively -``` - -API: `GET /api/screenshots` (list), `GET /api/screenshots/:name` (serve), `POST /api/screenshots` (upload multipart/form-data). Source: `src/web/public/upload.html`. - -## Debugging - -```bash -tmux list-sessions # List tmux sessions -tmux attach-session -t # Attach (Ctrl+B D to detach) -curl localhost:3000/api/sessions # Check sessions -curl localhost:3000/api/status | jq # Full app state -cat ~/.codeman/state.json | jq # View persisted state -curl localhost:3000/api/subagents # List background agents +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 ``` -## Troubleshooting +| 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 {}` | -| Problem | Check | Fix | -|---------|-------|-----| -| Session won't start | `tmux list-sessions` for orphans | Kill orphaned sessions, check Claude CLI installed | -| Port 3000 in use | `lsof -i :3000` | Kill conflicting process or use `--port` flag | -| SSE not connecting | Browser console for errors | Check CORS, ensure server running | -| Respawn not triggering | Session settings → Respawn enabled? | Enable respawn, check idle timeout config | -| Terminal blank on tab switch | Network tab for `/api/sessions/:id/buffer` | Check session exists, restart server | -| Tests failing on session limits | `tmux list-sessions \| wc -l` | Clean up: `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` | -| State not persisting | `cat ~/.codeman/state.json` | Check file permissions, disk space | +## Performance & Resource Limits -## Performance Constraints +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`). -The app must stay fast with 20 sessions and 50 agent windows: -- 60fps terminal (16ms batching + `requestAnimationFrame`) -- Auto-trimming buffers (2MB terminal max) -- Debounced state persistence (500ms) -- SSE adaptive batching: 16ms (normal), 32ms (moderate), 50ms (rapid); immediate flush at 32KB -- SSE backpressure handling: skip writes to backpressured clients, recover via `session:needsRefresh` on drain -- Cached endpoints: `/api/sessions` and `/api/status` use 1s TTL caches to avoid expensive serialization -- Frontend buffer loads: 128KB chunks via `requestAnimationFrame` to prevent UI jank +**Anti-flicker**: `PTY → Server Batching → DEC 2026 Wrap → SSE → Client rAF → xterm.js`. See `docs/terminal-anti-flicker.md`. -## Terminal Anti-Flicker System +**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. -Claude Code uses Ink (React for terminals), which redraws the screen on every state change. Codeman implements a 6-layer anti-flicker pipeline for smooth 60fps output: - -``` -PTY Output → Server Batching (16-50ms) → DEC 2026 Wrap → SSE → Client rAF → xterm.js -``` - -**Key functions:** `server.ts:batchTerminalData()`, `server.ts:flushTerminalBatches()`, `app.js:batchTerminalWrite()`, `app.js:extractSyncSegments()` - -**Typical latency:** 16-32ms. Optional per-session flicker filter adds ~50ms for problematic terminals. - -See `docs/terminal-anti-flicker.md` for full implementation details (adaptive batching, DEC 2026 markers, edge cases). - -## Resource Limits - -Limits are centralized in `src/config/` — see `buffer-limits.ts`, `map-limits.ts`, `server-timing.ts`, `auth-config.ts`, `tunnel-config.ts`, `terminal-limits.ts`, `ai-defaults.ts`, `team-config.ts`. - -**Buffer limits** (per session): -| Buffer | Max | Trim To | -|--------|-----|---------| -| Terminal | 2MB | 1.5MB | -| Text output | 1MB | 768KB | -| Messages | 1000 | 800 | - -**Map limits** (global): -| Resource | Max | -|----------|-----| -| Tracked agents | 500 | -| Concurrent sessions | 50 | -| SSE clients total | 100 | -| File watchers | 500 | - -Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-based cleanup. - -## Where to Find More Information +## 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 (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` | -| **API routes** | `src/web/routes/` domain modules, or README.md | -| **SSE events** | Search `broadcast(` in `server.ts` and route modules | -| **Session statuses** | `SessionStatus` in `src/types/session.ts` | -| **Error codes** | `createErrorResponse()` in `src/types/api.ts` | -| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase7-test-infrastructure-plan.md` | -| **Test utilities** | `test/respawn-test-utils.ts` | -| **Mobile test suite** | `mobile-test/README.md` | -| **OpenCode integration** | `docs/opencode-integration.md` | -| **Local echo overlay** | `docs/local-echo-overlay-plan.md` | -| **Performance investigation** | `docs/performance-investigation-report.md` | -| **First-load optimization** | `docs/first-load-optimization-plan.md`, `docs/perf-audit-first-load.md` | -| **Codebase quality / refactoring summary** | `docs/code-structure-findings.md` | -| **Dead code audit** | `docs/cleanup-findings.md` | -| **TypeScript improvements** | `docs/typescript-improvement-suggestions.md` | -| **Browser testing** | `docs/browser-testing-guide.md` | -| **Mobile testing report** | `docs/mobile-testing-report.md` | -| **Voice input** | `docs/voice-input-plan.md` | -| **Improvement roadmaps** | `docs/respawn-improvement-plan.md`, `docs/ralph-improvement-plan.md`, `docs/plan-improvement-roadmap.md` | -| **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` | -| **QR auth design** | `docs/qr-auth-plan.md` | -| **Run summary** | `docs/run-summary-plan.md` | - -Additional design docs and investigation reports are in the `docs/` directory. +| 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` | ## Scripts -| Script | Purpose | -|--------|---------| -| `scripts/tmux-manager.sh` | Safe tmux session management (use instead of direct kill commands) | -| `scripts/monitor-respawn.sh` | Monitor respawn state machine in real-time | -| `scripts/watch-subagents.ts` | Real-time subagent transcript watcher (list, follow by session/agent ID) | -| `scripts/codeman-web.service` | systemd service file for production deployment | -| `scripts/codeman-tunnel.service` | systemd service file for persistent Cloudflare tunnel | -| `scripts/tunnel.sh` | Start/stop/check Cloudflare quick tunnel (`./scripts/tunnel.sh start\|stop\|url`) | -| `scripts/build.mjs` | esbuild-based production build (called by `npm run build`) | -| `scripts/postinstall.js` | npm postinstall hook for setup | - -Additional scripts in `scripts/` for screenshots, demos, Ralph wizards, and browser testing. +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`. ## Memory Leak Prevention -Frontend runs long (24+ hour sessions); all Maps/timers must be cleaned up. - -### Cleanup Patterns -When adding new event listeners or timers: -1. Store handler references for later removal -2. Add cleanup to appropriate `stop()` or `cleanup*()` method -3. For singleton watchers, store refs in class properties and remove in server `stop()` - -**Backend**: Clear Maps in `stop()`, null promise callbacks on error, remove watcher listeners on shutdown. Use `CleanupManager` for centralized disposal — supports timers, intervals, watchers, listeners, streams. Guard async callbacks with `if (this.cleanup.isStopped) return`. - -**Frontend**: Store drag/resize handlers on elements, clean up in `close*()` functions. SSE reconnect calls `handleInit()` which resets state. SSE listeners are tracked in an array and removed on reconnect to prevent accumulation. - -Run `npx vitest run test/memory-leak-prevention.test.ts` to verify patterns. +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`. ## Common Workflows -**Investigating a bug**: Start dev server (`npx tsx src/index.ts web`), reproduce in browser, check terminal output and `~/.codeman/state.json` for clues. +**Investigating a bug**: Start dev server, reproduce in browser, check terminal + `~/.codeman/state.json`. -**Adding a new API endpoint**: Define types in the appropriate `src/types/*.ts` domain file, add route in the matching `src/web/routes/*-routes.ts` module, broadcast SSE events if needed, handle in `app.js:handleSSEEvent()`. +**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()`. -**Modifying respawn behavior**: Study `docs/respawn-state-machine.md` first. The state machine is in `respawn-controller.ts`. Use MockSession from `test/respawn-test-utils.ts` for testing. +**Modifying respawn**: Study `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`. -**Modifying mobile behavior**: Mobile singletons (`MobileDetection`, `KeyboardHandler`, `SwipeHandler`, `KeyboardAccessoryBar`) all have `init()`/`cleanup()` lifecycle. KeyboardHandler uses `visualViewport` API for iOS keyboard detection (100px threshold for address bar drift). All mobile handlers are re-initialized after SSE reconnect to prevent stale closures. +**Modifying mobile**: Singletons have `init()`/`cleanup()` lifecycle. Re-initialized after SSE reconnect to prevent stale closures. -**Adding a file watcher**: Use `ImageWatcher` as a template pattern — chokidar with `awaitWriteFinish`, burst throttling (max 20/10s), debouncing (200ms), and auto-ignore of `node_modules/.git/dist/`. +## Tunnel Setup -## Tunnel Setup (Remote Access) - -Access Codeman from mobile/remote devices via Cloudflare quick tunnel. - -``` -Browser → Cloudflare Edge (HTTPS) → cloudflared → localhost:3000 -``` - -**Prerequisites**: `cloudflared` installed (`cloudflared --version`), `CODEMAN_PASSWORD` set in environment. - -### Quick Start - -```bash -# Via CLI -./scripts/tunnel.sh start # Start tunnel, prints public URL -./scripts/tunnel.sh url # Show current URL -./scripts/tunnel.sh stop # Stop tunnel - -# Via web UI: Settings → Tunnel → Toggle On -``` - -### systemd Service (Persistent) - -```bash -# Install and enable -cp scripts/codeman-tunnel.service ~/.config/systemd/user/ -systemctl --user daemon-reload -systemctl --user enable --now codeman-tunnel - -# Check logs -journalctl --user -u codeman-tunnel -f -``` - -### Auth Flow - -1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`), or scan QR code from tunnel settings panel -2. On success → server issues `codeman_session` HttpOnly cookie (24h TTL, auto-extends on activity) -3. Subsequent requests → cookie authenticates silently (no more prompts) -4. SSE works automatically — `EventSource` sends same-origin cookies -5. 10 failed attempts per IP → 429 rate limit (15-minute decay) - -### Security Requirements - -- **Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access -- Session cookies are `Secure` when using `--https` flag; through Cloudflare tunnel without `--https`, cookies are non-Secure but traffic is still encrypted end-to-end via Cloudflare -- `/api/hook-event` bypasses auth (localhost-only Claude Code hooks need unauthenticated access) +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. diff --git a/README.md b/README.md index 3c6f2142..39bc9ac6 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,23 @@ Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/e ## Mobile-Optimized Web UI -The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work. +The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen. + + + + + + + + + + + + +
Mobile — landing page with QR authMobile — idle session with keyboard accessoryMobile — active agent session
Landing page with QR authKeyboard accessory barAgent working in real-time
- @@ -82,11 +94,22 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi - + +
Mobile — keyboard open Terminal Apps Codeman Mobile
No notificationsPush alerts for approvals and idle
Manual reconnecttmux persistence
No agent visibilityBackground agents in real-time
Copy-paste slash commandsOne-tap /init
Copy-paste slash commandsOne-tap /init, /clear, /compact
Password typing on phoneQR code scan — instant auth
-- **Swipe navigation** — left/right on the terminal to switch sessions (80px threshold, 300ms) +### Secure QR Code Authentication + +Typing passwords on a phone keyboard is miserable. Codeman replaces it with **cryptographically secure single-use QR tokens** — scan the code displayed on your desktop and your phone is authenticated instantly. + +Each QR encodes a URL containing a 6-character short code that maps to a 256-bit secret (`crypto.randomBytes(32)`) on the server. Tokens auto-rotate every **60 seconds**, are **atomically consumed on first scan** (replays always fail), and use **hash-based `Map.get()` lookup** that leaks nothing through response timing. The short code is an opaque pointer — the real secret never appears in browser history, `Referer` headers, or Cloudflare edge logs. + +The security design addresses all 6 critical QR auth flaws identified in ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025, which found 47 of the top-100 websites vulnerable): single-use enforcement, short TTL, cryptographic randomness, server-side generation, real-time desktop notification on scan (QRLjacking detection), and IP + User-Agent session binding with manual revocation. Dual-layer rate limiting (per-IP + global) makes brute force infeasible across 62^6 = 56.8 billion possible codes. Full security analysis: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md) + +### Touch-Optimized Interface + - **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard. Destructive commands (`/clear`, `/compact`) require a double-press to confirm — first tap arms the button, second tap executes — so you never fire one by accident on a bumpy commute +- **Swipe navigation** — left/right on the terminal to switch sessions (80px threshold, 300ms) - **Smart keyboard handling** — toolbar and terminal shift up when keyboard opens (uses `visualViewport` API with 100px threshold for iOS address bar drift) - **Safe area support** — respects iPhone notch and home indicator via `env(safe-area-inset-*)` - **44px touch targets** — all buttons meet iOS Human Interface Guidelines minimum sizes diff --git a/docs/screenshots/mobile-landing-qr.png b/docs/screenshots/mobile-landing-qr.png new file mode 100644 index 00000000..39900caf Binary files /dev/null and b/docs/screenshots/mobile-landing-qr.png differ diff --git a/docs/screenshots/mobile-session-active.png b/docs/screenshots/mobile-session-active.png new file mode 100644 index 00000000..dcc3549c Binary files /dev/null and b/docs/screenshots/mobile-session-active.png differ diff --git a/docs/screenshots/mobile-session-idle.png b/docs/screenshots/mobile-session-idle.png new file mode 100644 index 00000000..ed4b152e Binary files /dev/null and b/docs/screenshots/mobile-session-idle.png differ diff --git a/install.sh b/install.sh index 0266f1c4..febebab5 100755 --- a/install.sh +++ b/install.sh @@ -312,6 +312,32 @@ get_opencode_path() { done } +check_cloudflared() { + # Check ~/.local/bin first (matches tunnel-manager.ts resolution order) + if [[ -x "$HOME/.local/bin/cloudflared" ]]; then + return 0 + fi + if [[ -x "/usr/local/bin/cloudflared" ]]; then + return 0 + fi + if command -v cloudflared &>/dev/null; then + return 0 + fi + return 1 +} + +get_cloudflared_path() { + if [[ -x "$HOME/.local/bin/cloudflared" ]]; then + echo "$HOME/.local/bin/cloudflared" + return + fi + if [[ -x "/usr/local/bin/cloudflared" ]]; then + echo "/usr/local/bin/cloudflared" + return + fi + command -v cloudflared 2>/dev/null +} + # ============================================================================ # Dependency Installation # ============================================================================ @@ -541,6 +567,82 @@ install_git_suse() { run_as_root zypper install -y git } +install_cloudflared_macos() { + info "Installing cloudflared via Homebrew..." + ensure_homebrew + brew install cloudflared +} + +install_cloudflared_debian() { + info "Installing cloudflared..." + ensure_sudo + local arch + arch="$(dpkg --print-architecture 2>/dev/null || echo "amd64")" + local tmp + tmp="$(mktemp)" + download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$arch.deb" "$tmp" + run_as_root dpkg -i "$tmp" + rm -f "$tmp" +} + +install_cloudflared_fedora() { + info "Installing cloudflared..." + ensure_sudo + local arch + arch="$(uname -m)" + local rpm_arch="$arch" + [[ "$arch" == "x86_64" ]] && rpm_arch="x86_64" + [[ "$arch" == "aarch64" ]] && rpm_arch="aarch64" + local tmp + tmp="$(mktemp)" + download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$rpm_arch.rpm" "$tmp" + run_as_root rpm -i "$tmp" || run_as_root rpm -U "$tmp" + rm -f "$tmp" +} + +install_cloudflared_arch() { + info "Installing cloudflared binary..." + local arch + arch="$(uname -m)" + local cf_arch="amd64" + [[ "$arch" == "aarch64" ]] && cf_arch="arm64" + [[ "$arch" == "armv7l" ]] && cf_arch="arm" + ensure_sudo + local tmp + tmp="$(mktemp)" + download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$cf_arch" "$tmp" + run_as_root mv "$tmp" /usr/local/bin/cloudflared + run_as_root chmod +x /usr/local/bin/cloudflared +} + +install_cloudflared_alpine() { + info "Installing cloudflared binary..." + local arch + arch="$(uname -m)" + local cf_arch="amd64" + [[ "$arch" == "aarch64" ]] && cf_arch="arm64" + [[ "$arch" == "armv7l" ]] && cf_arch="arm" + ensure_sudo + local tmp + tmp="$(mktemp)" + download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$cf_arch" "$tmp" + run_as_root mv "$tmp" /usr/local/bin/cloudflared + run_as_root chmod +x /usr/local/bin/cloudflared +} + +install_cloudflared_suse() { + info "Installing cloudflared..." + ensure_sudo + local arch + arch="$(uname -m)" + local rpm_arch="$arch" + local tmp + tmp="$(mktemp)" + download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$rpm_arch.rpm" "$tmp" + run_as_root rpm -i "$tmp" || run_as_root rpm -U "$tmp" + rm -f "$tmp" +} + # ============================================================================ # Interactive Prompts # ============================================================================ @@ -733,6 +835,22 @@ EOF success "Systemd service installed and started" } +setup_tunnel_service() { + local service_dir="$HOME/.config/systemd/user" + local service_file="$service_dir/codeman-tunnel.service" + + info "Setting up Cloudflare tunnel systemd service..." + + mkdir -p "$service_dir" + cp "$INSTALL_DIR/scripts/codeman-tunnel.service" "$service_file" + + systemctl --user daemon-reload + systemctl --user enable codeman-tunnel.service 2>/dev/null || true + + success "Tunnel service installed (start with: systemctl --user start codeman-tunnel)" + echo -e " ${DIM}Note: Set CODEMAN_PASSWORD env var before starting the tunnel for security.${NC}" +} + # ============================================================================ # Installation Helpers # ============================================================================ @@ -915,6 +1033,24 @@ main() { fi fi + # cloudflared (optional — for remote/mobile access via Cloudflare Tunnel) + info "Checking cloudflared (optional, for remote access)..." + if check_cloudflared; then + success "cloudflared found at $(get_cloudflared_path)" + else + if prompt_yes_no "Install cloudflared? (enables remote/mobile access via Cloudflare Tunnel)" "n"; then + install_dependency "cloudflared" "$os" "$distro" + hash -r 2>/dev/null || true + if check_cloudflared; then + success "cloudflared installed at $(get_cloudflared_path)" + else + warn "cloudflared installation failed. You can install it manually later." + fi + else + info "Skipped (you can install cloudflared later for remote access)" + fi + fi + echo "" # ======================================================================== @@ -989,18 +1125,7 @@ main() { fi # ======================================================================== - # Systemd Service (Linux only) - # ======================================================================== - - if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then - echo "" - if prompt_yes_no "Set up systemd service for auto-start?" "n"; then - setup_systemd_service - fi - fi - - # ======================================================================== - # Success! + # Launch Options # ======================================================================== echo "" @@ -1009,13 +1134,73 @@ main() { echo -e "${GREEN}${BOLD}============================================================${NC}" echo "" - # Check if systemd service is running (we just started it above) - local service_running=false - if systemctl --user is-active codeman-web.service &>/dev/null; then - service_running=true + local launch_choice="" + local has_systemd=false + + if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then + has_systemd=true fi - if [[ "$service_running" == "true" ]]; then + if [[ "$has_systemd" == "true" ]]; then + echo -e " ${BOLD}How would you like to run Codeman?${NC}" + echo "" + echo -e " ${CYAN}1)${NC} Run now in this terminal" + echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)" + echo -e " ${CYAN}3)${NC} Don't start — I'll run it later" + echo "" + + if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then + launch_choice="3" + else + while true; do + echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2 + read -r launch_choice + case "$launch_choice" in + 1|2|3) break ;; + *) echo "Please enter 1, 2, or 3." >&2 ;; + esac + done + fi + else + # macOS or no systemd — only offer run now or skip + echo -e " ${BOLD}Would you like to start Codeman now?${NC}" + echo "" + echo -e " ${CYAN}1)${NC} Run now in this terminal" + echo -e " ${CYAN}2)${NC} Don't start — I'll run it later" + echo "" + + if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then + launch_choice="2" + else + while true; do + echo -en "${CYAN}Choose [1/2]:${NC} " >&2 + read -r launch_choice + case "$launch_choice" in + 1) break ;; + 2) break ;; + *) echo "Please enter 1 or 2." >&2 ;; + esac + done + fi + # Remap: no-systemd choice "2" (skip) → internal "3" + [[ "$launch_choice" == "2" ]] && launch_choice="3" + fi + + echo "" + + # Handle systemd setup + if [[ "$launch_choice" == "2" ]]; then + setup_systemd_service + + # Offer tunnel service if cloudflared is available + if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then + echo "" + if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then + setup_tunnel_service + fi + fi + + echo "" echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}" echo "" echo -e " ${CYAN}# Open in browser${NC}" @@ -1028,20 +1213,29 @@ main() { echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status" echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs" echo "" - else + fi + + # Show quick-start help for non-service paths + if [[ "$launch_choice" != "2" ]]; then echo -e " ${BOLD}Quick Start:${NC}" echo "" - echo -e " ${CYAN}# Start the web server${NC}" - echo -e " codeman web" - echo "" - echo -e " ${CYAN}# Start with HTTPS (only needed for remote access)${NC}" - echo -e " codeman web --https" + echo -e " ${CYAN}codeman web${NC} # Start the web server" + echo -e " ${CYAN}codeman web --https${NC} # With HTTPS (for remote access)" echo "" echo -e " ${CYAN}# Open in browser${NC}" echo -e " http://localhost:3000" echo "" fi + if check_cloudflared; then + echo -e " ${BOLD}Remote Access (Cloudflare Tunnel):${NC}" + echo "" + echo -e " ${CYAN}./scripts/tunnel.sh start${NC} # Start tunnel" + echo -e " ${CYAN}./scripts/tunnel.sh url${NC} # Show tunnel URL" + echo -e " ${CYAN}./scripts/tunnel.sh stop${NC} # Stop tunnel" + echo "" + fi + echo -e " ${BOLD}Mobile Access (Termius/SSH):${NC}" echo "" echo -e " ${CYAN}sc${NC} # Interactive tmux session chooser" @@ -1060,16 +1254,19 @@ main() { echo "" fi - # Check if PATH needs reload in user's shell (only relevant if service not running) - if [[ "$service_running" != "true" ]]; then + # Run now in foreground (must be last — exec replaces the shell) + if [[ "$launch_choice" == "1" ]]; then local profile profile=$(detect_shell_profile) - if ! command -v codeman &>/dev/null 2>&1; then - echo -e " ${YELLOW}Run this to start using codeman now:${NC}" - echo "" - echo -e " ${CYAN}source $profile && codeman web${NC}" - echo "" - fi + + echo -e " ${GREEN}${BOLD}Starting Codeman...${NC}" + echo -e " ${DIM}Press Ctrl+C to stop${NC}" + echo "" + + # Source profile to pick up PATH changes, then exec codeman + # shellcheck disable=SC1090 + source "$profile" 2>/dev/null || true + exec node "$INSTALL_DIR/dist/index.js" web fi } @@ -1095,21 +1292,23 @@ uninstall() { info "Uninstalling Codeman..." echo "" - # Stop and remove systemd service - if systemctl --user is-active codeman-web.service &>/dev/null; then - info "Stopping codeman-web service..." - systemctl --user stop codeman-web.service - fi - if systemctl --user is-enabled codeman-web.service &>/dev/null 2>&1; then - info "Disabling codeman-web service..." - systemctl --user disable codeman-web.service 2>/dev/null || true - fi - local service_file="$HOME/.config/systemd/user/codeman-web.service" - if [[ -f "$service_file" ]]; then - rm -f "$service_file" - systemctl --user daemon-reload 2>/dev/null || true - success "Systemd service removed" - fi + # Stop and remove systemd services + for svc in codeman-web codeman-tunnel; do + if systemctl --user is-active "${svc}.service" &>/dev/null; then + info "Stopping ${svc} service..." + systemctl --user stop "${svc}.service" + fi + if systemctl --user is-enabled "${svc}.service" &>/dev/null 2>&1; then + info "Disabling ${svc} service..." + systemctl --user disable "${svc}.service" 2>/dev/null || true + fi + local svc_file="$HOME/.config/systemd/user/${svc}.service" + if [[ -f "$svc_file" ]]; then + rm -f "$svc_file" + success "Removed ${svc} service" + fi + done + systemctl --user daemon-reload 2>/dev/null || true # Remove symlinks local symlink_dir="$HOME/.local/bin" diff --git a/scripts/build.mjs b/scripts/build.mjs index 2b21541b..b0e5fff5 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -12,6 +12,7 @@ */ import { execSync } from 'child_process'; +import { appendFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { join } from 'path'; @@ -37,6 +38,22 @@ run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile= run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js'); run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js'); run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js'); +run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js'); + +// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)` +appendFileSync( + join(ROOT, 'dist/web/public/vendor/xterm-zerolag-input.js'), + '\n// Global aliases for browser usage\n' + + 'if(typeof window!=="undefined"){' + + 'window.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' + + 'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' + + 'constructor(terminal){' + + 'super({prompt:{type:"character",char:"\\u276f",offset:2}});' + + 'this.activate(terminal);' + + '}' + + '};' + + '}\n' +); // 4. Minify frontend assets run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite'); diff --git a/scripts/postinstall.js b/scripts/postinstall.js index ede25bf6..08c572d3 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -276,6 +276,34 @@ if (isGlobalInstall) { // WebGL addon: copy unminified (matches build script behavior) copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js')); + + // xterm-zerolag-input: bundle local package as IIFE for - + @@ -961,6 +961,10 @@ + @@ -1670,14 +1674,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 2dd7c6ab..325df15e 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -1,9 +1,32 @@ +/** + * @fileoverview Mobile keyboard accessory bar and modal focus trap. + * + * Defines two exports: + * + * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual + * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss. + * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). + * Commands are sent as text + Enter separately for Ink compatibility. + * Only initializes on touch devices (MobileDetection.isTouchDevice guard). + * + * - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element. + * Saves and restores previously focused element on deactivate. Used by Ralph wizard + * and other modal dialogs. + * + * @globals {object} KeyboardAccessoryBar + * @globals {class} FocusTrap + * + * @dependency mobile-handlers.js (MobileDetection.isTouchDevice) + * @dependency app.js (uses global `app` for sendInput, activeSessionId, terminal) + * @loadorder 5 of 9 — loaded after notification-manager.js, before app.js + */ + // Codeman — Keyboard accessory bar and focus trap for modals // Loaded after mobile-handlers.js, before app.js -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ // Mobile Keyboard Accessory Bar -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ /** * KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing. @@ -209,9 +232,9 @@ const KeyboardAccessoryBar = { } }; -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ // Accessibility: Focus Trap for Modals -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ /** * FocusTrap - Traps keyboard focus within an element (typically a modal). diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 64b98383..413fbcb6 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -1,9 +1,33 @@ +/** + * @fileoverview Mobile device support: detection, keyboard handling, and swipe navigation. + * + * Defines three singleton objects that manage mobile-specific behavior: + * + * - MobileDetection — Device type detection (mobile/tablet/desktop), touch capability, + * iOS/Safari identification, and body class management for CSS targeting. + * - KeyboardHandler — Virtual keyboard show/hide detection via visualViewport API, + * toolbar/accessory bar repositioning, terminal resize on keyboard open/close, + * and input scroll-into-view. Uses 100px threshold for iOS address bar drift. + * - SwipeHandler — Horizontal swipe detection on the terminal area for session switching. + * 80px minimum distance, 300ms maximum time, 100px max vertical drift. + * + * All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE + * reconnect (in handleInit) to prevent stale closures. + * + * @globals {object} MobileDetection + * @globals {object} KeyboardHandler + * @globals {object} SwipeHandler + * + * @dependency keyboard-accessory.js (KeyboardAccessoryBar reference in KeyboardHandler.onKeyboardShow, soft — guarded with typeof check) + * @loadorder 2 of 9 — loaded after constants.js, before voice-input.js + */ + // Codeman — Mobile detection, keyboard handling, and swipe navigation // Loaded after constants.js, before app.js -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ // Mobile Detection -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ /** * MobileDetection - Detects device type and touch capability. @@ -109,9 +133,9 @@ const MobileDetection = { } }; -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ // Mobile Keyboard Handler -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ /** * KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears. @@ -387,9 +411,9 @@ const KeyboardHandler = { } }; -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ // Mobile Swipe Handler -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ /** * SwipeHandler - Detects horizontal swipes on terminal to switch sessions. diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js index 3377e688..9d3c3319 100644 --- a/src/web/public/notification-manager.js +++ b/src/web/public/notification-manager.js @@ -1,3 +1,30 @@ +/** + * @fileoverview Five-layer notification system for session events and alerts. + * + * The NotificationManager class implements five notification layers: + * 1. In-app notification drawer (slide-out panel with grouped notifications) + * 2. Tab title flash (alternating "(*) Codeman" when tab is hidden) + * 3. Browser Notification API (desktop push with auto-close after 8s) + * 4. Web Push via service worker (OS-level notifications when tab is closed) + * 5. Audio alerts (Web Audio API beep, user-opt-in) + * + * Features: + * - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration + * - Device-specific defaults (notifications disabled on mobile by default) + * - 5s notification grouping window to batch rapid-fire events + * - 100-notification cap with oldest eviction + * - Rate limiting: 3s between browser notifications + * - Visibility tracking (pauses title flash when tab becomes visible) + * - iOS Safari bfcache support via pageshow event + * + * @class NotificationManager + * @param {CodemanApp} app - Reference to the main app instance + * + * @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants) + * @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults) + * @loadorder 4 of 9 — loaded after voice-input.js, before keyboard-accessory.js + */ + // Codeman — Multi-layer notification system // Loaded after mobile-handlers.js, before app.js diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index 6c3d4118..bf6e8f29 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -1,10 +1,30 @@ /** - * Ralph Loop Wizard — extracted from app.js for maintainability. - * Extends CodemanApp.prototype with wizard methods. - * Loaded after app.js in index.html. + * @fileoverview Ralph Loop Wizard — multi-step modal for configuring autonomous task loops. + * + * Extends CodemanApp.prototype with wizard methods for the Ralph Loop setup flow: + * Step 1: Task description, completion phrase, iteration limit, case selection + * Step 2: AI-powered plan generation (optional) with research agent → planner agent pipeline + * Step 3: Respawn configuration (idle timeout, kickstart prompt, auto-clear/init) + * Step 4: Review and launch + * + * Features: + * - Plan generation via POST /api/sessions/:id/plan/generate with SSE progress streaming + * - Existing @fix_plan.md detection and reuse + * - Plan detail level selection (brief/detailed/comprehensive) + * - Case selector population from /api/cases + * - Focus trap for modal accessibility + * - Abort controller for cancelling in-flight plan generation + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class must be defined) + * @dependency keyboard-accessory.js (FocusTrap class for modal focus management) + * @dependency constants.js (escapeHtml) + * @loadorder 7 of 9 — loaded after app.js, before api-client.js */ -// ========== Ralph Loop Wizard ========== +// ═══════════════════════════════════════════════════════════════ +// Ralph Loop Wizard +// ═══════════════════════════════════════════════════════════════ Object.assign(CodemanApp.prototype, { @@ -388,7 +408,9 @@ Object.assign(CodemanApp.prototype, { } }, - // ========== Plan Generation ========== + // ═══════════════════════════════════════════════════════════════ + // Plan Generation + // ═══════════════════════════════════════════════════════════════ resetPlanGenerationUI() { // Hide all plan generation states @@ -780,7 +802,9 @@ Object.assign(CodemanApp.prototype, { this.closePlanSubagentWindows(); }, - // ========== Plan Subagent Windows ========== + // ═══════════════════════════════════════════════════════════════ + // Plan Subagent Windows + // ═══════════════════════════════════════════════════════════════ handlePlanSubagentEvent(event) { if (this.planGenerationStopped) return; diff --git a/src/web/public/subagent-windows.js b/src/web/public/subagent-windows.js index 1bf4d6df..8c5a312b 100644 --- a/src/web/public/subagent-windows.js +++ b/src/web/public/subagent-windows.js @@ -1,3 +1,23 @@ +/** + * @fileoverview Subagent floating window management mixed into CodemanApp.prototype. + * + * Extends CodemanApp with methods for managing floating terminal windows that display + * Claude Code background agent (subagent) output. Each subagent window has its own + * xterm.js terminal instance, drag/resize handles, minimize/close controls, and + * connection lines drawn to the parent session tab. + * + * Key functionality: + * - Tab badge dropdown showing minimized agents per session + * - Minimize/restore/permanently-close lifecycle for subagent windows + * - Cross-browser state persistence (localStorage + server-backed PUT /api/subagent-window-states) + * - Window state saved on every minimize/restore/close action + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.minimizedSubagents) + * @dependency constants.js (escapeHtml) + * @loadorder 9 of 9 — loaded last, after api-client.js + */ + // Codeman — Subagent window management for CodemanApp // Loaded after app.js (needs CodemanApp class defined) @@ -80,7 +100,9 @@ Object.assign(CodemanApp.prototype, { this.saveSubagentWindowStates(); }, - // ========== Subagent Window State Persistence ========== + // ═══════════════════════════════════════════════════════════════ + // Subagent Window State Persistence + // ═══════════════════════════════════════════════════════════════ /** * Save subagent window states (minimized/open) to server for cross-browser persistence. @@ -198,7 +220,9 @@ Object.assign(CodemanApp.prototype, { }); }, - // ========== Subagent Connection Lines ========== + // ═══════════════════════════════════════════════════════════════ + // Subagent Connection Lines + // ═══════════════════════════════════════════════════════════════ // // Connection lines are drawn from agent windows to their parent TABs. // The parent TAB is determined by the PERSISTENT subagentParentMap. @@ -434,7 +458,9 @@ Object.assign(CodemanApp.prototype, { } }, - // ========== Subagent Floating Windows ========== + // ═══════════════════════════════════════════════════════════════ + // Subagent Floating Windows + // ═══════════════════════════════════════════════════════════════ openSubagentWindow(agentId) { // If window already exists, focus it diff --git a/src/web/public/sw.js b/src/web/public/sw.js index 27db293d..5d87bbb3 100644 --- a/src/web/public/sw.js +++ b/src/web/public/sw.js @@ -1,3 +1,18 @@ +/** + * @fileoverview Service worker for Web Push notifications. + * + * Receives push events from the Codeman server (via web-push library) and displays + * OS-level notifications. Handles notification clicks to focus an existing Codeman + * tab or open a new one. Supports action buttons, per-session deep linking, and + * critical notification persistence (requireInteraction). + * + * Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest + * service worker takes control immediately without waiting for tab refresh. + * + * @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts) + * @see src/push-store.ts — server-side VAPID key management and subscription CRUD + */ + // Codeman Service Worker — Web Push notifications // This service worker receives push events from the server and displays OS-level notifications. // It also handles notification clicks to focus or open the Codeman tab. diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index 279b0146..bf4fbbd5 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -1,9 +1,33 @@ +/** + * @fileoverview Voice input with Deepgram Nova-3 (primary) and Web Speech API (fallback). + * + * Defines two singleton objects: + * + * - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text. + * Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings, + * auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary. + * + * - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap + * again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording + * indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct" + * (inject into local echo overlay or PTY) and "compose" (editable textarea overlay). + * Includes a temporary green Send button that replaces the settings gear icon after voice input. + * Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check. + * + * @globals {object} DeepgramProvider + * @globals {object} VoiceInput + * + * @dependency mobile-handlers.js (MobileDetection for device checks) + * @dependency app.js (uses global `app` for sendInput, showToast, terminal focus) + * @loadorder 3 of 9 — loaded after mobile-handlers.js, before notification-manager.js + */ + // Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback // Loaded after mobile-handlers.js, before app.js -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ // Voice Input (Deepgram Nova-3 + Web Speech API fallback) -// ============================================================================ +// ═══════════════════════════════════════════════════════════════ /** * DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API. diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 33920b00..a4b236cf 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -10,6 +10,7 @@ import { homedir } from 'node:os'; import { Session } from '../session.js'; import { ApiErrorCode, createErrorResponse } from '../types.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; +import { SseEvent } from './sse-events.js'; import type { SessionPort } from './ports/session-port.js'; import type { EventPort } from './ports/event-port.js'; @@ -131,7 +132,7 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev console.log( `[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}` ); - ctx.broadcast('session:ralphLoopUpdate', { + ctx.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state: session.ralphTracker.loopState, }); @@ -146,7 +147,7 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev session.ralphTracker.enable(); session.ralphTracker.startLoop(completionPhrase); console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`); - ctx.broadcast('session:ralphLoopUpdate', { + ctx.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state: session.ralphTracker.loopState, }); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 21ca881a..8494a379 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -15,10 +15,15 @@ import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { CASES_DIR } from '../route-helpers.js'; +import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void { - // ============ Case CRUD ============ + // ═══════════════════════════════════════════════════════════════ + // Case CRUD (list, create, link, detail, fix-plan) + // ═══════════════════════════════════════════════════════════════ + + // ========== List Cases ========== app.get('/api/cases', async (): Promise => { const cases: CaseInfo[] = []; @@ -95,7 +100,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Write .claude/settings.local.json with hooks for desktop notifications await writeHooksConfig(casePath); - ctx.broadcast('case:created', { name, path: casePath }); + ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath }); return { success: true, data: { case: { name, path: casePath } } }; } catch (err) { @@ -152,7 +157,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config mkdirSync(codemanDir, { recursive: true }); } await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); - ctx.broadcast('case:linked', { name, path: expandedPath }); + ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath }); return { success: true, data: { case: { name, path: expandedPath } } }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); @@ -321,7 +326,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } }); - // ============ Ralph Wizard File Endpoints ============ + // ═══════════════════════════════════════════════════════════════ + // Ralph Wizard Files (per-case prompt/result serving) + // ═══════════════════════════════════════════════════════════════ + + // ========== List Wizard Files ========== app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { const { caseName } = req.params as { caseName: string }; diff --git a/src/web/routes/plan-routes.ts b/src/web/routes/plan-routes.ts index a5d99a40..c92e06d6 100644 --- a/src/web/routes/plan-routes.ts +++ b/src/web/routes/plan-routes.ts @@ -18,10 +18,15 @@ import { PlanTaskAddSchema, } from '../schemas.js'; import { findSessionOrFail, CASES_DIR } from '../route-helpers.js'; +import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort): void { - // ============ Plan Generation Endpoints ============ + // ═══════════════════════════════════════════════════════════════ + // Plan Generation (simple AI + detailed orchestration) + // ═══════════════════════════════════════════════════════════════ + + // ========== Generate Plan (Simple) ========== app.post('/api/generate-plan', async (req): Promise => { const gpResult = GeneratePlanSchema.safeParse(req.body); @@ -215,8 +220,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } }); - // Generate detailed implementation plan using subagent orchestration - // This spawns multiple specialist subagents in parallel for thorough analysis + // ========== Generate Plan (Detailed Orchestration) ========== + app.post('/api/generate-plan-detailed', async (req): Promise => { const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body); if (!gpdResult.success) { @@ -257,7 +262,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; ctx.activePlanOrchestrators.set(orchestratorId, orchestrator); // Broadcast the orchestrator ID so frontend can cancel if needed - ctx.broadcast('plan:started', { orchestratorId }); + ctx.broadcast(SseEvent.PlanStarted, { orchestratorId }); // Track progress for SSE updates const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = []; @@ -265,7 +270,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const update = { phase, detail, timestamp: Date.now() }; progressUpdates.push(update); // Broadcast progress to connected clients - ctx.broadcast('plan:progress', update); + ctx.broadcast(SseEvent.PlanProgress, update); }; // Broadcast plan subagent events for UI visibility @@ -280,7 +285,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; durationMs?: number; error?: string; }) => { - ctx.broadcast('plan:subagent', event); + ctx.broadcast(SseEvent.PlanSubagent, event); }; try { @@ -292,7 +297,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Clean up orchestrator from active map ctx.activePlanOrchestrators.delete(orchestratorId); - ctx.broadcast('plan:completed', { orchestratorId, success: result.success }); + ctx.broadcast(SseEvent.PlanCompleted, { orchestratorId, success: result.success }); if (!result.success) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed'); @@ -311,7 +316,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } catch (err) { // Clean up on error too ctx.activePlanOrchestrators.delete(orchestratorId); - ctx.broadcast('plan:completed', { + ctx.broadcast(SseEvent.PlanCompleted, { orchestratorId, success: false, error: getErrorMessage(err), @@ -323,7 +328,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } }); - // Cancel active plan generation + // ========== Cancel Plan Generation ========== + app.post('/api/cancel-plan-generation', async (req): Promise => { const cpResult = CancelPlanSchema.safeParse(req.body); if (!cpResult.success) { @@ -340,7 +346,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; console.log(`[API] Cancelling plan generation ${orchestratorId}`); await orchestrator.cancel(); ctx.activePlanOrchestrators.delete(orchestratorId); - ctx.broadcast('plan:cancelled', { orchestratorId }); + ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId }); return { success: true, data: { cancelled: orchestratorId } }; } @@ -350,17 +356,19 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; console.log(`[API] Cancelling plan generation ${id}`); await orchestrator.cancel(); cancelled.push(id); - ctx.broadcast('plan:cancelled', { orchestratorId: id }); + ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId: id }); } ctx.activePlanOrchestrators.clear(); return { success: true, data: { cancelled } }; }); - // ============ Plan Management Endpoints ============ - // These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning + // ═══════════════════════════════════════════════════════════════ + // Plan Management (task CRUD, checkpoints, version history, rollback) + // ═══════════════════════════════════════════════════════════════ + + // ========== Update Plan Task ========== - // Update a specific plan task (status, attempts, errors) app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => { const { id, taskId } = req.params as { id: string; taskId: string }; const session = findSessionOrFail(ctx, id); @@ -385,11 +393,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found'); } - ctx.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task }); + ctx.broadcast(SseEvent.SessionPlanTaskUpdate, { sessionId: id, taskId, update: result.task }); return { success: true, data: result.task }; }); - // Trigger a checkpoint review (at iterations 5, 10, 20, etc.) + // ========== Create Checkpoint ========== + app.post('/api/sessions/:id/plan/checkpoint', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); @@ -400,11 +409,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } const checkpoint = tracker.generateCheckpointReview(); - ctx.broadcast('session:planCheckpoint', { sessionId: id, checkpoint }); + ctx.broadcast(SseEvent.SessionPlanCheckpoint, { sessionId: id, checkpoint }); return { success: true, data: checkpoint }; }); - // Get plan version history + // ========== Get Version History ========== + app.get('/api/sessions/:id/plan/history', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); @@ -417,7 +427,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return { success: true, data: tracker.getPlanHistory() }; }); - // Rollback to a previous plan version + // ========== Rollback to Version ========== + app.post('/api/sessions/:id/plan/rollback/:version', async (req) => { const { id, version } = req.params as { id: string; version: string }; const session = findSessionOrFail(ctx, id); @@ -432,11 +443,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found'); } - ctx.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) }); + ctx.broadcast(SseEvent.SessionPlanRollback, { sessionId: id, version: parseInt(version, 10) }); return { success: true, data: result.plan }; }); - // Add a new task to the plan (for runtime adaptation) + // ========== Add Plan Task ========== + app.post('/api/sessions/:id/plan/task', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); @@ -453,7 +465,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const task = ptaResult.data; const result = tracker.addPlanTask(task); - ctx.broadcast('session:planTaskAdded', { sessionId: id, task: result.task }); + ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task }); return { success: true, data: result.task }; }); } diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 2b9eecb2..b1029c8a 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -12,6 +12,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } import { Session } from '../../session.js'; import { RespawnController } from '../../respawn-controller.js'; import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js'; +import { SseEvent } from '../sse-events.js'; import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; @@ -23,6 +24,10 @@ export function registerRalphRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort ): void { + // ═══════════════════════════════════════════════════════════════ + // Ralph Tracker Configuration & Status + // ═══════════════════════════════════════════════════════════════ + // Configure Ralph tracker for a session app.post('/api/sessions/:id/ralph-config', async (req) => { const { id } = req.params as { id: string }; @@ -95,7 +100,7 @@ export function registerRalphRoutes( // Persist and broadcast the update ctx.persistSessionState(session); - ctx.broadcast('session:ralphLoopUpdate', { + ctx.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: id, state: session.ralphLoopState, }); @@ -136,6 +141,10 @@ export function registerRalphRoutes( }; }); + // ═══════════════════════════════════════════════════════════════ + // Fix Plan CRUD (@fix_plan.md generation, import, read/write) + // ═══════════════════════════════════════════════════════════════ + // Generate @fix_plan.md content from todos app.get('/api/sessions/:id/fix-plan', async (req) => { const { id } = req.params as { id: string }; @@ -249,6 +258,10 @@ export function registerRalphRoutes( } }); + // ═══════════════════════════════════════════════════════════════ + // Ralph Prompt & Loop (prompt write, loop start) + // ═══════════════════════════════════════════════════════════════ + // Write Ralph prompt to file in session's working directory // This avoids mux input escaping issues with long multi-line prompts app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { @@ -320,7 +333,7 @@ export function registerRalphRoutes( const claudeMd = generateClaudeMd(caseName, '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); await writeHooksConfig(casePath); - ctx.broadcast('case:created', { name: caseName, path: casePath }); + ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath }); } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); } @@ -441,7 +454,7 @@ export function registerRalphRoutes( name: session.name, reason: 'ralph_loop_start', }); - ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session)); // Start interactive mode try { @@ -452,8 +465,8 @@ export function registerRalphRoutes( name: session.name, mode: 'claude', }); - ctx.broadcast('session:interactive', { id: session.id, mode: 'claude' }); - ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'claude' }); + ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); } catch (err) { await ctx.cleanupSession(session.id, true, 'ralph_loop_start_error'); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); @@ -475,7 +488,7 @@ export function registerRalphRoutes( controller.start(); ctx.saveRespawnConfig(session.id, controller.getConfig()); ctx.persistSessionState(session); - ctx.broadcast('respawn:started', { + ctx.broadcast(SseEvent.RespawnStarted, { sessionId: session.id, status: controller.getStatus(), }); diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 85c25d9c..09dc8324 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespawnConfig } from '../../types.js'; import { RespawnController, type RespawnConfig } from '../../respawn-controller.js'; import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js'; +import { SseEvent } from '../sse-events.js'; import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; @@ -25,7 +26,12 @@ export function registerRespawnRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort ): void { - // Get respawn status for a session + // ═══════════════════════════════════════════════════════════════ + // Respawn Status & Config + // ═══════════════════════════════════════════════════════════════ + + // ========== Get Respawn Status ========== + app.get('/api/sessions/:id/respawn', async (req) => { const { id } = req.params as { id: string }; const controller = ctx.respawnControllers.get(id); @@ -40,7 +46,8 @@ export function registerRespawnRoutes( }; }); - // Get respawn config (from running controller or pre-saved) + // ========== Get Respawn Config ========== + app.get('/api/sessions/:id/respawn/config', async (req) => { const { id } = req.params as { id: string }; const controller = ctx.respawnControllers.get(id); @@ -58,7 +65,12 @@ export function registerRespawnRoutes( return { success: true, config: null, active: false }; }); - // Start respawn controller for a session + // ═══════════════════════════════════════════════════════════════ + // Respawn Start & Stop + // ═══════════════════════════════════════════════════════════════ + + // ========== Start Respawn ========== + app.post('/api/sessions/:id/respawn/start', async (req) => { const { id } = req.params as { id: string }; let body: Partial | undefined; @@ -95,12 +107,13 @@ export function registerRespawnRoutes( ctx.saveRespawnConfig(id, controller.getConfig()); ctx.persistSessionState(session); - ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); + ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() }); return { success: true, status: controller.getStatus() }; }); - // Stop respawn controller for a session + // ========== Stop Respawn ========== + app.post('/api/sessions/:id/respawn/stop', async (req) => { const { id } = req.params as { id: string }; const controller = ctx.respawnControllers.get(id); @@ -130,12 +143,13 @@ export function registerRespawnRoutes( ctx.persistSessionState(session); } - ctx.broadcast('respawn:stopped', { sessionId: id }); + ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id }); return { success: true }; }); - // Update respawn configuration (works with or without running controller) + // ========== Update Respawn Config ========== + app.put('/api/sessions/:id/respawn/config', async (req) => { const { id } = req.params as { id: string }; // Validate respawn config to prevent arbitrary field injection @@ -153,7 +167,7 @@ export function registerRespawnRoutes( controller.updateConfig(config); ctx.saveRespawnConfig(id, controller.getConfig()); ctx.persistSessionState(session); - ctx.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); + ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() }); return { success: true, config: controller.getConfig() }; } @@ -186,11 +200,16 @@ export function registerRespawnRoutes( }; ctx.mux.updateRespawnConfig(id, merged); ctx.persistSessionState(session); - ctx.broadcast('respawn:configUpdated', { sessionId: id, config: merged }); + ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged }); return { success: true, config: merged }; }); - // Start interactive session WITH respawn enabled + // ═══════════════════════════════════════════════════════════════ + // Composite Actions (interactive-respawn, enable on existing) + // ═══════════════════════════════════════════════════════════════ + + // ========== Interactive Respawn (start session + respawn in one call) ========== + app.post('/api/sessions/:id/interactive-respawn', async (req) => { const { id } = req.params as { id: string }; const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} }; @@ -230,8 +249,8 @@ export function registerRespawnRoutes( mode: session.mode, reason: 'interactive_respawn', }); - ctx.broadcast('session:interactive', { id }); - ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + ctx.broadcast(SseEvent.SessionInteractive, { id }); + ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); // Create and start respawn controller const controller = new RespawnController(session, body?.respawnConfig); @@ -247,7 +266,7 @@ export function registerRespawnRoutes( // Persist full session state with respawn config ctx.persistSessionState(session); - ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); + ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() }); return { success: true, @@ -261,7 +280,8 @@ export function registerRespawnRoutes( } }); - // Enable respawn on an EXISTING interactive session + // ========== Enable Respawn on Existing Session ========== + app.post('/api/sessions/:id/respawn/enable', async (req) => { const { id } = req.params as { id: string }; const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} }; @@ -304,7 +324,7 @@ export function registerRespawnRoutes( ctx.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes); ctx.persistSessionState(session); - ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); + ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() }); return { success: true, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index a1dc8668..1ff303c0 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -17,6 +17,7 @@ import { type SessionColor, } from '../../types.js'; import { Session } from '../../session.js'; +import { SseEvent } from '../sse-events.js'; import { CreateSessionSchema, SessionNameSchema, @@ -54,6 +55,10 @@ export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ): void { + // ═══════════════════════════════════════════════════════════════ + // Auth + // ═══════════════════════════════════════════════════════════════ + // ========== Logout ========== app.post('/api/logout', async (req, reply) => { @@ -66,6 +71,10 @@ export function registerSessionRoutes( return { success: true }; }); + // ═══════════════════════════════════════════════════════════════ + // Session CRUD (list, create, rename, color, delete, detail) + // ═══════════════════════════════════════════════════════════════ + // ========== Session Listing ========== app.get('/api/sessions', async () => { @@ -146,7 +155,7 @@ export function registerSessionRoutes( // 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 = ctx.getSessionStateWithRespawn(session); - ctx.broadcast('session:created', lightState); + ctx.broadcast(SseEvent.SessionCreated, lightState); return { success: true, session: lightState }; }); @@ -170,7 +179,7 @@ export function registerSessionRoutes( // Also update the mux session name if applicable ctx.mux.updateSessionName(id, session.name); ctx.persistSessionState(session); - ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); return { success: true, name: session.name }; }); @@ -196,7 +205,7 @@ export function registerSessionRoutes( session.setColor(body.color as SessionColor); ctx.persistSessionState(session); - ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); return { success: true, color: session.color }; }); @@ -246,6 +255,10 @@ export function registerSessionRoutes( return ctx.getSessionStateWithRespawn(session); }); + // ═══════════════════════════════════════════════════════════════ + // Session Data (output, ralph state, run summary, active tools) + // ═══════════════════════════════════════════════════════════════ + // ========== Get Session Output ========== app.get('/api/sessions/:id/output', async (req) => { @@ -328,6 +341,10 @@ export function registerSessionRoutes( }; }); + // ═══════════════════════════════════════════════════════════════ + // Session Execution (run prompt, interactive mode, shell mode) + // ═══════════════════════════════════════════════════════════════ + // ========== Run Prompt ========== app.post('/api/sessions/:id/run', async (req): Promise => { @@ -349,10 +366,10 @@ export function registerSessionRoutes( // Run async, don't wait session.runPrompt(prompt).catch((err) => { - ctx.broadcast('session:error', { id, error: err.message }); + ctx.broadcast(SseEvent.SessionError, { id, error: err.message }); }); - ctx.broadcast('session:running', { id, prompt }); + ctx.broadcast(SseEvent.SessionRunning, { id, prompt }); return { success: true }; }); @@ -391,8 +408,8 @@ export function registerSessionRoutes( name: session.name, mode: session.mode, }); - ctx.broadcast('session:interactive', { id }); - ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + ctx.broadcast(SseEvent.SessionInteractive, { id }); + ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); return { success: true }; } catch (err) { @@ -422,14 +439,18 @@ export function registerSessionRoutes( name: session.name, mode: 'shell', }); - ctx.broadcast('session:interactive', { id, mode: 'shell' }); - ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' }); + ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); + // ═══════════════════════════════════════════════════════════════ + // Terminal I/O (input, resize, buffer) + // ═══════════════════════════════════════════════════════════════ + // ========== Send Input ========== app.post('/api/sessions/:id/input', async (req): Promise => { @@ -550,6 +571,10 @@ export function registerSessionRoutes( }; }); + // ═══════════════════════════════════════════════════════════════ + // Session Settings (auto-clear, auto-compact, image watcher, flicker filter) + // ═══════════════════════════════════════════════════════════════ + // ========== Auto-Clear ========== app.post('/api/sessions/:id/auto-clear', async (req) => { @@ -567,7 +592,7 @@ export function registerSessionRoutes( session.setAutoClear(body.enabled, body.threshold); ctx.persistSessionState(session); - ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); return { success: true, @@ -597,7 +622,7 @@ export function registerSessionRoutes( session.setAutoCompact(body.enabled, body.threshold, body.prompt); ctx.persistSessionState(session); - ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); return { success: true, @@ -661,7 +686,7 @@ export function registerSessionRoutes( session.flickerFilterEnabled = body.enabled; ctx.persistSessionState(session); - ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); return { success: true, @@ -671,6 +696,10 @@ export function registerSessionRoutes( }; }); + // ═══════════════════════════════════════════════════════════════ + // Quick Actions (quick-run, quick-start) + // ═══════════════════════════════════════════════════════════════ + // ========== Quick Run ========== app.post('/api/run', async (req) => { @@ -717,7 +746,7 @@ export function registerSessionRoutes( reason: 'run_prompt', }); - ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session)); try { const result = await session.runPrompt(prompt); @@ -786,7 +815,7 @@ export function registerSessionRoutes( await writeHooksConfig(casePath); } - ctx.broadcast('case:created', { name: caseName, path: casePath }); + ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath }); } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); } @@ -831,7 +860,7 @@ export function registerSessionRoutes( name: session.name, reason: 'quick_start', }); - ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session)); + ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session)); // Start in the appropriate mode try { @@ -843,7 +872,7 @@ export function registerSessionRoutes( name: session.name, mode: 'shell', }); - ctx.broadcast('session:interactive', { id: session.id, mode: 'shell' }); + ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' }); } else { // Both 'claude' and 'opencode' modes use startInteractive() await session.startInteractive(); @@ -853,9 +882,9 @@ export function registerSessionRoutes( name: session.name, mode, }); - ctx.broadcast('session:interactive', { id: session.id, mode }); + ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode }); } - ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); // Save lastUsedCase to settings for TUI/web sync try { diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 98f7f98b..914ed6dd 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -25,6 +25,7 @@ import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js'; +import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; @@ -91,6 +92,10 @@ export function registerSystemRoutes( const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json'); const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json'); + // ═══════════════════════════════════════════════════════════════ + // System Status & Health + // ═══════════════════════════════════════════════════════════════ + // ========== Status ========== app.get('/api/status', async () => ctx.getLightState()); @@ -120,6 +125,10 @@ export function registerSystemRoutes( } }); + // ═══════════════════════════════════════════════════════════════ + // Authentication (QR auth, session revocation) + // ═══════════════════════════════════════════════════════════════ + // ========== QR Auth Route ========== app.get('/q/:code', async (req, reply) => { @@ -177,7 +186,7 @@ export function registerSystemRoutes( }); // Broadcast auth notification — desktop sees who authenticated - ctx.broadcast('tunnel:qrAuthUsed', { + ctx.broadcast(SseEvent.TunnelQrAuthUsed, { ip: clientIp, ua: clientUA, timestamp: Date.now(), @@ -206,6 +215,10 @@ export function registerSystemRoutes( return { success: true }; }); + // ═══════════════════════════════════════════════════════════════ + // CLI Integrations (OpenCode) + // ═══════════════════════════════════════════════════════════════ + // ========== OpenCode ========== app.get('/api/opencode/status', async () => { @@ -216,6 +229,10 @@ export function registerSystemRoutes( }; }); + // ═══════════════════════════════════════════════════════════════ + // State & Lifecycle (cleanup, lifecycle log, stats) + // ═══════════════════════════════════════════════════════════════ + // ========== State & Lifecycle ========== app.post('/api/cleanup-state', async () => { @@ -279,6 +296,10 @@ export function registerSystemRoutes( }; }); + // ═══════════════════════════════════════════════════════════════ + // Configuration & Settings (config, settings, model config, CPU priority) + // ═══════════════════════════════════════════════════════════════ + // ========== Config ========== app.get('/api/config', async () => { @@ -424,7 +445,7 @@ export function registerSystemRoutes( console.log('Tunnel started via settings change'); } else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) { // Tunnel already running — re-emit so the client gets the URL - ctx.broadcast('tunnel:started', { url: ctx.tunnelManager.getUrl() }); + ctx.broadcast(SseEvent.TunnelStarted, { url: ctx.tunnelManager.getUrl() }); console.log('Tunnel already running, re-broadcast URL to client'); } else if (!tunnelEnabled && ctx.tunnelManager.isRunning()) { ctx.tunnelManager.stop(); @@ -506,7 +527,7 @@ export function registerSystemRoutes( session.setNice(body); ctx.persistSessionState(session); - ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); return { success: true, @@ -515,6 +536,10 @@ export function registerSystemRoutes( }; }); + // ═══════════════════════════════════════════════════════════════ + // Subagent Management (window states, parents, monitoring, transcripts) + // ═══════════════════════════════════════════════════════════════ + // ========== Subagent Window State Persistence ========== app.get('/api/subagent-window-states', async () => { @@ -643,6 +668,10 @@ export function registerSystemRoutes( return { success: true, data: { cleared } }; }); + // ═══════════════════════════════════════════════════════════════ + // Screenshots (upload, list, serve) + // ═══════════════════════════════════════════════════════════════ + // ========== Screenshots ========== app.post('/api/screenshots', async (req, reply) => { diff --git a/src/web/server.ts b/src/web/server.ts index 9b618640..20a29af2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,11 +1,28 @@ /** - * @fileoverview Codeman web server and REST API + * @fileoverview Codeman web server — central hub coordinating all subsystems. * - * Provides a Fastify-based web server with: - * - REST API for session management, respawn control, and monitoring - * - Server-Sent Events (SSE) for real-time updates at /api/events - * - Static file serving for the web UI - * - 60fps terminal streaming with batched updates + * Fastify-based web server providing: + * - ~111 REST API routes (delegated to `src/web/routes/` domain modules) + * - SSE streaming at `/api/events` with backpressure handling + * - Static file serving for the web UI (1-year cache in production) + * - 60fps terminal streaming via batched PTY output (16-50ms adaptive) + * + * Coordinates: SessionManager, RespawnController, SubagentWatcher, TeamWatcher, + * TranscriptWatcher, ImageWatcher, TunnelManager, PushSubscriptionStore, + * PlanOrchestrator, RunSummaryTracker, FileStreamManager. + * + * Key exports: + * - `WebServer` class — implements all port interfaces, extends EventEmitter + * - `startWebServer(options)` — factory function to create and start the server + * + * Implements port interfaces: `SessionPort`, `EventPort`, `ConfigPort`, + * `RespawnPort`, `MuxPort`, `FilePort`, `ScheduledPort`, `PushPort`, `TeamPort` + * (see `src/web/ports/` for definitions) + * + * @dependencies All major subsystems (session, respawn-controller, subagent-watcher, + * team-watcher, tunnel-manager, state-store, etc.) + * @consumedby src/index.ts (entry point), src/cli.ts + * @emits SSE events via broadcast() — see sse-events.ts for full registry * * @module web/server */ @@ -70,6 +87,7 @@ import { } from '../types.js'; import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js'; import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js'; +import { SseEvent } from './sse-events.js'; import type { ScheduledRun } from './ports/index.js'; import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js'; import { @@ -280,10 +298,10 @@ export class WebServer extends EventEmitter { // Set up mux event listeners this.mux.on('sessionCreated', (session) => { - this.broadcast('mux:created', session); + this.broadcast(SseEvent.MuxCreated, session); }); this.mux.on('sessionKilled', (data) => { - this.broadcast('mux:killed', data); + this.broadcast(SseEvent.MuxKilled, data); }); this.mux.on('sessionDied', (data) => { getLifecycleLog().log({ @@ -291,10 +309,10 @@ export class WebServer extends EventEmitter { sessionId: (data as { sessionId?: string }).sessionId || 'unknown', extra: data as Record, }); - this.broadcast('mux:died', data); + this.broadcast(SseEvent.MuxDied, data); }); this.mux.on('statsUpdated', (sessions) => { - this.broadcast('mux:statsUpdated', sessions); + this.broadcast(SseEvent.MuxStatsUpdated, sessions); }); // Set up subagent watcher listeners @@ -308,16 +326,16 @@ export class WebServer extends EventEmitter { // Set up tunnel manager listeners this.tunnelManager.on('started', (data: { url: string }) => { - this.broadcast('tunnel:started', data); + this.broadcast(SseEvent.TunnelStarted, data); }); this.tunnelManager.on('stopped', () => { - this.broadcast('tunnel:stopped', {}); + this.broadcast(SseEvent.TunnelStopped, {}); }); this.tunnelManager.on('error', (message: string) => { - this.broadcast('tunnel:error', { message }); + this.broadcast(SseEvent.TunnelError, { message }); }); this.tunnelManager.on('progress', (data: { message: string }) => { - this.broadcast('tunnel:progress', data); + this.broadcast(SseEvent.TunnelProgress, data); }); // QR token rotation — broadcast inline SVG for instant desktop refresh @@ -326,7 +344,7 @@ export class WebServer extends EventEmitter { if (url && process.env.CODEMAN_PASSWORD) { try { const svg = await this.tunnelManager.getQrSvg(url); - this.broadcast('tunnel:qrRotated', { svg }); + this.broadcast(SseEvent.TunnelQrRotated, { svg }); } catch { // QR generation failed — skip this rotation } @@ -338,7 +356,7 @@ export class WebServer extends EventEmitter { if (url && process.env.CODEMAN_PASSWORD) { try { const svg = await this.tunnelManager.getQrSvg(url); - this.broadcast('tunnel:qrRegenerated', { svg }); + this.broadcast(SseEvent.TunnelQrRegenerated, { svg }); } catch { // QR generation failed — skip } @@ -357,13 +375,13 @@ export class WebServer extends EventEmitter { private setupSubagentWatcherListeners(): void { // Store handlers for cleanup on shutdown this.subagentWatcherHandlers = { - discovered: (info: SubagentInfo) => this.broadcast('subagent:discovered', info), - updated: (info: SubagentInfo) => this.broadcast('subagent:updated', info), - toolCall: (data: SubagentToolCall) => this.broadcast('subagent:tool_call', data), - toolResult: (data: SubagentToolResult) => this.broadcast('subagent:tool_result', data), - progress: (data: SubagentProgress) => this.broadcast('subagent:progress', data), - message: (data: SubagentMessage) => this.broadcast('subagent:message', data), - completed: (info: SubagentInfo) => this.broadcast('subagent:completed', info), + discovered: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentDiscovered, info), + updated: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentUpdated, info), + toolCall: (data: SubagentToolCall) => this.broadcast(SseEvent.SubagentToolCall, data), + toolResult: (data: SubagentToolResult) => this.broadcast(SseEvent.SubagentToolResult, data), + progress: (data: SubagentProgress) => this.broadcast(SseEvent.SubagentProgress, data), + message: (data: SubagentMessage) => this.broadcast(SseEvent.SubagentMessage, data), + completed: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentCompleted, info), error: (error: Error, agentId?: string) => { console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message); }, @@ -403,7 +421,7 @@ export class WebServer extends EventEmitter { private setupImageWatcherListeners(): void { // Store handlers for cleanup on shutdown this.imageWatcherHandlers = { - detected: (event: ImageDetectedEvent) => this.broadcast('image:detected', event), + detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event), error: (error: Error, sessionId?: string) => { console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message); }, @@ -430,10 +448,10 @@ export class WebServer extends EventEmitter { */ private setupTeamWatcherListeners(): void { this.teamWatcherHandlers = { - teamCreated: (config: unknown) => this.broadcast('team:created', config), - teamUpdated: (config: unknown) => this.broadcast('team:updated', config), - teamRemoved: (config: unknown) => this.broadcast('team:removed', config), - taskUpdated: (data: unknown) => this.broadcast('team:taskUpdated', data), + teamCreated: (config: unknown) => this.broadcast(SseEvent.TeamCreated, config), + teamUpdated: (config: unknown) => this.broadcast(SseEvent.TeamUpdated, config), + teamRemoved: (config: unknown) => this.broadcast(SseEvent.TeamRemoved, config), + taskUpdated: (data: unknown) => this.broadcast(SseEvent.TeamTaskUpdated, data), }; this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated); @@ -580,7 +598,7 @@ export class WebServer extends EventEmitter { // Send initial state // Use light state for SSE init to avoid sending 2MB+ terminal buffers // Buffers are fetched on-demand when switching tabs - this.sendSSE(reply, 'init', this.getLightState()); + 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()) { @@ -640,7 +658,7 @@ export class WebServer extends EventEmitter { if (controller) { controller.signalTranscriptComplete(); } - this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() }); + this.broadcast(SseEvent.TranscriptComplete, { sessionId, timestamp: Date.now() }); }); watcher.on('transcript:plan_mode', () => { @@ -648,15 +666,15 @@ export class WebServer extends EventEmitter { if (controller) { controller.signalTranscriptPlanMode(); } - this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() }); + this.broadcast(SseEvent.TranscriptPlanMode, { sessionId, timestamp: Date.now() }); }); watcher.on('transcript:tool_start', (toolName: string) => { - this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() }); + this.broadcast(SseEvent.TranscriptToolStart, { sessionId, toolName, timestamp: Date.now() }); }); watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => { - this.broadcast('transcript:tool_end', { + this.broadcast(SseEvent.TranscriptToolEnd, { sessionId, toolName, isError, @@ -806,7 +824,7 @@ export class WebServer extends EventEmitter { controller.removeAllListeners(); this.respawnControllers.delete(sessionId); // Notify UI that respawn is stopped for this session - this.broadcast('respawn:stopped', { sessionId, reason: 'session_cleanup' }); + this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'session_cleanup' }); } // Clear respawn timer @@ -858,7 +876,7 @@ export class WebServer extends EventEmitter { this.store.removeRalphState(sessionId); // Broadcast Ralph cleared to update UI - this.broadcast('session:ralphLoopUpdate', { + this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId, state: { enabled: false, @@ -871,7 +889,7 @@ export class WebServer extends EventEmitter { elapsedHours: null, }, }); - this.broadcast('session:ralphTodoUpdate', { + this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId, todos: [], stats: { total: 0, pending: 0, inProgress: 0, completed: 0 }, @@ -941,7 +959,7 @@ export class WebServer extends EventEmitter { } } - this.broadcast('session:deleted', { id: sessionId }); + this.broadcast(SseEvent.SessionDeleted, { id: sessionId }); } private async setupSessionListeners(session: Session): Promise { @@ -960,49 +978,58 @@ export class WebServer extends EventEmitter { imageWatcher.watchSession(session.id, session.workingDir); } - // Store all listener references for explicit cleanup on session delete - // This prevents memory leaks from closure references keeping objects alive + // Store all listener references for explicit cleanup on session delete. + // This prevents memory leaks from closure references keeping objects alive. const listeners: SessionListenerRefs = { + // ─── Terminal Output ───────────────────────────────────── + // These listeners handle raw PTY output streaming to SSE clients. + + /** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */ terminal: (data) => { - // Use batching for better performance at high throughput this.batchTerminalData(session.id, data); }, + /** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */ clearTerminal: () => { - // Tell clients to clear their terminal (after mux attach) - this.broadcast('session:clearTerminal', { id: session.id }); + this.broadcast(SseEvent.SessionClearTerminal, { id: session.id }); }, + /** Broadcasts `session:needsRefresh` — tells clients to reload buffer (e.g., after OpenCode TUI stabilizes) */ needsRefresh: () => { - // Tell clients to reload the terminal buffer (e.g., after OpenCode TUI stabilizes) - this.broadcast('session:needsRefresh', { id: session.id }); + this.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id }); }, + // ─── Session Messages & Errors ────────────────────────── + + /** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */ message: (msg: ClaudeMessage) => { - this.broadcast('session:message', { id: session.id, message: msg }); + this.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg }); }, + /** Broadcasts `session:error` + sends push notification */ error: (error) => { - this.broadcast('session:error', { id: session.id, error }); - this.sendPushNotifications('session:error', { + this.broadcast(SseEvent.SessionError, { id: session.id, error }); + this.sendPushNotifications(SseEvent.SessionError, { sessionId: session.id, sessionName: session.name, error: String(error), }); - // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordError('Session error', String(error)); }, + /** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */ completion: (result, cost) => { - this.broadcast('session:completion', { id: session.id, result, cost }); - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); + this.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost }); + this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session)); this.persistSessionState(session); - // Track tokens in run summary (completion event has updated token values) const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens); }, + // ─── Session Lifecycle ────────────────────────────────── + + /** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */ exit: (code) => { getLifecycleLog().log({ event: 'exit', @@ -1012,8 +1039,8 @@ export class WebServer extends EventEmitter { }); // Wrap in try/catch to ensure cleanup always happens try { - this.broadcast('session:exit', { id: session.id, code }); - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); + this.broadcast(SseEvent.SessionExit, { id: session.id, code }); + this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session)); this.persistSessionState(session); } catch (err) { console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err); @@ -1107,9 +1134,11 @@ export class WebServer extends EventEmitter { } }, + // ─── Activity State ───────────────────────────────────── + + /** Broadcasts `session:working` — Claude started processing */ working: () => { - this.broadcast('session:working', { id: session.id }); - // Track in run summary + this.broadcast(SseEvent.SessionWorking, { id: session.id }); const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.recordWorking(); @@ -1117,11 +1146,10 @@ export class WebServer extends EventEmitter { } }, + /** Broadcasts `session:idle` — Claude finished processing, waiting for input */ idle: () => { - this.broadcast('session:idle', { id: session.id }); - // Use debounced state update (idle can fire frequently) + this.broadcast(SseEvent.SessionIdle, { id: session.id }); this.broadcastSessionStateDebounced(session.id); - // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.recordIdle(); @@ -1129,78 +1157,87 @@ export class WebServer extends EventEmitter { } }, - // Background task events - use debounced state updates to reduce serialization overhead + // ─── Background Task Events ────────────────────────────── + // Debounced state updates to reduce serialization overhead. + + /** Broadcasts `task:created` — new background task discovered */ taskCreated: (task: BackgroundTask) => { - this.broadcast('task:created', { sessionId: session.id, task }); + this.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task }); this.broadcastSessionStateDebounced(session.id); }, + /** Batched broadcast of `task:updated` — high-frequency progress updates */ taskUpdated: (task: BackgroundTask) => { - // Use batching for better performance at high update rates this.batchTaskUpdate(session.id, task); }, + /** Broadcasts `task:completed` — background task finished successfully */ taskCompleted: (task: BackgroundTask) => { - this.broadcast('task:completed', { sessionId: session.id, task }); + this.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task }); this.broadcastSessionStateDebounced(session.id); }, + /** Broadcasts `task:failed` — background task errored */ taskFailed: (task: BackgroundTask, error: string) => { - this.broadcast('task:failed', { sessionId: session.id, task, error }); + this.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error }); this.broadcastSessionStateDebounced(session.id); }, + // ─── Auto-Operations ──────────────────────────────────── + + /** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */ autoClear: (data: { tokens: number; threshold: number }) => { - this.broadcast('session:autoClear', { sessionId: session.id, ...data }); + this.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data }); this.broadcastSessionStateDebounced(session.id); - // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordAutoClear(data.tokens, data.threshold); }, + /** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */ autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => { - this.broadcast('session:autoCompact', { sessionId: session.id, ...data }); + this.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data }); this.broadcastSessionStateDebounced(session.id); - // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold); }, - // Claude Code CLI info parsed from terminal (version, model, account) + // ─── CLI Info ──────────────────────────────────────────── + + /** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */ cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => { - this.broadcast('session:cliInfo', { sessionId: session.id, ...data }); + this.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data }); this.broadcastSessionStateDebounced(session.id); }, - // Ralph tracking events + // ─── Ralph Tracking Events ────────────────────────────── + + /** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */ ralphLoopUpdate: (state: RalphTrackerState) => { - this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state }); - // Persist Ralph state + this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state }); this.store.updateRalphState(session.id, { loop: state }); }, + /** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */ ralphTodoUpdate: (todos: RalphTodoItem[]) => { - this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos }); - // Persist Ralph state + this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos }); this.store.updateRalphState(session.id, { todos }); }, + /** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */ ralphCompletionDetected: (phrase: string) => { - this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase }); - this.sendPushNotifications('session:ralphCompletionDetected', { + this.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase }); + this.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, sessionName: session.name, phrase, }); - // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordRalphCompletion(phrase); }, - // RALPH_STATUS block events + /** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */ ralphStatusBlockDetected: (block: import('../types.js').RalphStatusBlock) => { - this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block }); - // Track in run summary + this.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block }); const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.addEvent( @@ -1212,18 +1249,18 @@ export class WebServer extends EventEmitter { } }, + /** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */ ralphCircuitBreakerUpdate: (status: import('../types.js').CircuitBreakerStatus) => { - this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status }); - // Track state changes in run summary + this.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status }); const tracker = this.runSummaryTrackers.get(session.id); if (tracker && status.state === 'OPEN') { tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason); } }, + /** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */ ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => { - this.broadcast('session:exitGateMet', { sessionId: session.id, ...data }); - // Track in run summary + this.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data }); const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.addEvent( @@ -1235,17 +1272,22 @@ export class WebServer extends EventEmitter { } }, - // Bash tool tracking events (for clickable file paths) + // ─── Bash Tool Tracking ──────────────────────────────── + // Used for clickable file paths in the UI. + + /** Broadcasts `session:bashToolStart` — bash tool invocation started */ bashToolStart: (tool: ActiveBashTool) => { - this.broadcast('session:bashToolStart', { sessionId: session.id, tool }); + this.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool }); }, + /** Broadcasts `session:bashToolEnd` — bash tool invocation completed */ bashToolEnd: (tool: ActiveBashTool) => { - this.broadcast('session:bashToolEnd', { sessionId: session.id, tool }); + this.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool }); }, + /** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */ bashToolsUpdate: (tools: ActiveBashTool[]) => { - this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools }); + this.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools }); }, }; @@ -1287,83 +1329,103 @@ export class WebServer extends EventEmitter { // Helper to get tracker lazily (may not exist at setup time for restored sessions) const getTracker = () => this.runSummaryTrackers.get(sessionId); + // ─── Respawn State Machine ────────────────────────────── + + /** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */ controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => { - this.broadcast('respawn:stateChanged', { sessionId, state, prevState }); - // Track in run summary (lazy lookup since tracker may be created after controller) + this.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState }); const tracker = getTracker(); if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`); }); + // ─── Respawn Cycle Lifecycle ──────────────────────────── + + /** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */ controller.on('respawnCycleStarted', (cycleNumber: number) => { - this.broadcast('respawn:cycleStarted', { sessionId, cycleNumber }); + this.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber }); }); + /** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */ controller.on('respawnCycleCompleted', (cycleNumber: number) => { - this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber }); + this.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber }); }); + /** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */ controller.on('respawnBlocked', (data: { reason: string; details: string }) => { - this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details }); + this.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details }); const sessionForPush = this.sessions.get(sessionId); - this.sendPushNotifications('respawn:blocked', { + this.sendPushNotifications(SseEvent.RespawnBlocked, { sessionId, sessionName: sessionForPush?.name ?? sessionId.slice(0, 8), reason: data.reason, }); - // Track in run summary (lazy lookup) const tracker = getTracker(); if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details); }); + // ─── Respawn Step Progress ────────────────────────────── + + /** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */ controller.on('stepSent', (step: string, input: string) => { - this.broadcast('respawn:stepSent', { sessionId, step, input }); + this.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input }); }); + /** Broadcasts `respawn:stepCompleted` — respawn step finished */ controller.on('stepCompleted', (step: string) => { - this.broadcast('respawn:stepCompleted', { sessionId, step }); + this.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step }); }); + /** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */ controller.on('detectionUpdate', (detection: unknown) => { - this.broadcast('respawn:detectionUpdate', { sessionId, detection }); + this.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection }); }); + /** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */ controller.on('autoAcceptSent', () => { - this.broadcast('respawn:autoAcceptSent', { sessionId }); + this.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId }); }); + // ─── AI Checker Events ────────────────────────────────── + + /** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */ controller.on('aiCheckStarted', () => { - this.broadcast('respawn:aiCheckStarted', { sessionId }); + this.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId }); }); + /** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */ controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { - this.broadcast('respawn:aiCheckCompleted', { + this.broadcast(SseEvent.RespawnAiCheckCompleted, { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs, }); - // Track in run summary (lazy lookup) const tracker = getTracker(); if (tracker) tracker.recordAiCheckResult(result.verdict); }); + /** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */ controller.on('aiCheckFailed', (error: string) => { - this.broadcast('respawn:aiCheckFailed', { sessionId, error }); - // Track in run summary (lazy lookup) + this.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error }); const tracker = getTracker(); if (tracker) tracker.recordError('AI check failed', error); }); + /** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */ controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => { - this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt }); + this.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt }); }); + // ─── Plan Checker Events ──────────────────────────────── + + /** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */ controller.on('planCheckStarted', () => { - this.broadcast('respawn:planCheckStarted', { sessionId }); + this.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId }); }); + /** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */ controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { - this.broadcast('respawn:planCheckCompleted', { + this.broadcast(SseEvent.RespawnPlanCheckCompleted, { sessionId, verdict: result.verdict, reasoning: result.reasoning, @@ -1371,34 +1433,43 @@ export class WebServer extends EventEmitter { }); }); + /** Broadcasts `respawn:planCheckFailed` — plan check errored */ controller.on('planCheckFailed', (error: string) => { - this.broadcast('respawn:planCheckFailed', { sessionId, error }); + this.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error }); }); - // Timer tracking events for UI countdown display + // ─── Timer Events (UI countdown display) ──────────────── + + /** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */ controller.on('timerStarted', (timer) => { - this.broadcast('respawn:timerStarted', { sessionId, timer }); + this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer }); }); + /** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */ controller.on('timerCancelled', (timerName, reason) => { - this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason }); + this.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason }); }); + /** Broadcasts `respawn:timerCompleted` — timer expired */ controller.on('timerCompleted', (timerName) => { - this.broadcast('respawn:timerCompleted', { sessionId, timerName }); + this.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName }); }); + // ─── Logging & Errors ─────────────────────────────────── + + /** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */ controller.on('actionLog', (action) => { - this.broadcast('respawn:actionLog', { sessionId, action }); + this.broadcast(SseEvent.RespawnActionLog, { sessionId, action }); }); + /** Broadcasts `respawn:log` — general respawn log message */ controller.on('log', (message: string) => { - this.broadcast('respawn:log', { sessionId, message }); + this.broadcast(SseEvent.RespawnLog, { sessionId, message }); }); + /** Broadcasts `respawn:error` — respawn controller error */ controller.on('error', (error: Error) => { - this.broadcast('respawn:error', { sessionId, error: error.message }); - // Track in run summary (lazy lookup) + this.broadcast(SseEvent.RespawnError, { sessionId, error: error.message }); const tracker = getTracker(); if (tracker) tracker.recordError('Respawn error', error.message); }); @@ -1422,7 +1493,7 @@ export class WebServer extends EventEmitter { controller.stop(); controller.removeAllListeners(); this.respawnControllers.delete(sessionId); - this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' }); + this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' }); } this.respawnTimers.delete(sessionId); // Update persisted state (respawn no longer active) @@ -1435,7 +1506,7 @@ export class WebServer extends EventEmitter { ); this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now }); - this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now }); + this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now }); } /** @@ -1494,7 +1565,7 @@ export class WebServer extends EventEmitter { const ctrl = this.respawnControllers.get(session.id); if (ctrl && ctrl.state === 'stopped') { ctrl.start(); - this.broadcast('respawn:started', { sessionId: session.id }); + this.broadcast(SseEvent.RespawnStarted, { sessionId: session.id }); console.log(`[Server] Restored respawn controller started for session ${session.id}`); } }, delayMs); @@ -1607,7 +1678,7 @@ export class WebServer extends EventEmitter { }; this.scheduledRuns.set(id, run); - this.broadcast('scheduled:created', run); + this.broadcast(SseEvent.ScheduledCreated, run); // Start the run loop (fire-and-forget with error handling) this.runScheduledLoop(id).catch((err) => { @@ -1616,7 +1687,7 @@ export class WebServer extends EventEmitter { if (failedRun && failedRun.status === 'running') { failedRun.status = 'stopped'; failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`); - this.broadcast('scheduled:stopped', { id, reason: 'error' }); + this.broadcast(SseEvent.ScheduledStopped, { id, reason: 'error' }); } }); @@ -1629,7 +1700,7 @@ export class WebServer extends EventEmitter { const addLog = (msg: string) => { run.logs.push(`[${new Date().toISOString()}] ${msg}`); - this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] }); + this.broadcast(SseEvent.ScheduledLog, { id: runId, log: run.logs[run.logs.length - 1] }); }; while (Date.now() < run.endAt && run.status === 'running') { @@ -1651,7 +1722,7 @@ export class WebServer extends EventEmitter { run.sessionId = session.id; addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`); - this.broadcast('scheduled:updated', run); + this.broadcast(SseEvent.ScheduledUpdated, run); // Run the prompt const timeRemaining = Math.round((run.endAt - Date.now()) / 60000); @@ -1662,7 +1733,7 @@ export class WebServer extends EventEmitter { run.totalCost += result.cost; addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`); - this.broadcast('scheduled:updated', run); + this.broadcast(SseEvent.ScheduledUpdated, run); // Clean up the session after iteration to prevent memory leaks await this.cleanupSession(session.id, true, 'scheduled_run'); @@ -1672,7 +1743,7 @@ export class WebServer extends EventEmitter { await new Promise((r) => setTimeout(r, ITERATION_PAUSE_MS)); } catch (err) { addLog(`Error: ${getErrorMessage(err)}`); - this.broadcast('scheduled:updated', run); + this.broadcast(SseEvent.ScheduledUpdated, run); // Clean up the session on error too if (session) { @@ -1694,7 +1765,7 @@ export class WebServer extends EventEmitter { addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`); } - this.broadcast('scheduled:completed', run); + this.broadcast(SseEvent.ScheduledCompleted, run); } private async stopScheduledRun(id: string): Promise { @@ -1710,7 +1781,7 @@ export class WebServer extends EventEmitter { run.sessionId = null; } - this.broadcast('scheduled:stopped', run); + this.broadcast(SseEvent.ScheduledStopped, run); } /** @@ -1761,7 +1832,7 @@ export class WebServer extends EventEmitter { for (const id of toDelete) { this.scheduledRuns.delete(id); - this.broadcast('scheduled:deleted', { id }); + this.broadcast(SseEvent.ScheduledDeleted, { id }); } if (toDelete.length > 0) { @@ -1847,7 +1918,7 @@ export class WebServer extends EventEmitter { // Client may have missed terminal data during backpressure. // Tell it to reload the active session's buffer to recover. try { - reply.raw.write(`event: session:needsRefresh\ndata: {}\n\n`); + reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n`); } catch { /* client gone */ } @@ -1867,7 +1938,7 @@ export class WebServer extends EventEmitter { // 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 === 'session:created' || event === 'session:deleted' || event === 'session:updated') { + if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted || event === SseEvent.SessionUpdated) { this.cachedLightState = null; this.cachedSessionsList = null; } @@ -2004,7 +2075,7 @@ export class WebServer extends EventEmitter { return; } for (const [, { sessionId, task }] of this.taskUpdateBatches) { - this.broadcast('task:updated', { sessionId, task }); + this.broadcast(SseEvent.TaskUpdated, { sessionId, task }); } this.taskUpdateBatches.clear(); } @@ -2042,7 +2113,7 @@ export class WebServer extends EventEmitter { const session = this.sessions.get(sessionId); if (session) { // Single expensive serialization per batch interval - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); + this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session)); } } this.stateUpdatePending.clear(); @@ -2055,7 +2126,7 @@ export class WebServer extends EventEmitter { string, { title: string; urgency: string; actions?: Array<{ action: string; title: string }> } > = { - 'hook:permission_prompt': { + [SseEvent.HookPermissionPrompt]: { title: 'Permission Required', urgency: 'critical', actions: [ @@ -2063,12 +2134,12 @@ export class WebServer extends EventEmitter { { action: 'deny', title: 'Deny' }, ], }, - 'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' }, - 'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' }, - 'hook:stop': { title: 'Response Complete', urgency: 'info' }, - 'session:error': { title: 'Session Error', urgency: 'critical' }, - 'respawn:blocked': { title: 'Respawn Blocked', urgency: 'critical' }, - 'session:ralphCompletionDetected': { title: 'Task Complete', urgency: 'warning' }, + [SseEvent.HookElicitationDialog]: { title: 'Question Asked', urgency: 'critical' }, + [SseEvent.HookIdlePrompt]: { title: 'Waiting for Input', urgency: 'warning' }, + [SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' }, + [SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' }, + [SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' }, + [SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' }, }; /** @@ -2091,16 +2162,16 @@ export class WebServer extends EventEmitter { // Build body text from event data let body = sessionName ? `[${sessionName}]` : ''; - if (event === 'session:error' && data.error) { + if (event === SseEvent.SessionError && data.error) { body += body ? ' ' : ''; body += String(data.error).slice(0, 200); - } else if (event === 'respawn:blocked' && data.reason) { + } else if (event === SseEvent.RespawnBlocked && data.reason) { body += body ? ' ' : ''; body += String(data.reason); - } else if (event === 'session:ralphCompletionDetected' && data.phrase) { + } else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) { body += body ? ' ' : ''; body += String(data.phrase); - } else if (event === 'hook:permission_prompt' && data.tool_name) { + } else if (event === SseEvent.HookPermissionPrompt && data.tool_name) { body += body ? ' ' : ''; body += `Tool: ${String(data.tool_name)}`; } diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts new file mode 100644 index 00000000..f8990d66 --- /dev/null +++ b/src/web/sse-events.ts @@ -0,0 +1,447 @@ +/** + * @fileoverview Centralized SSE event type registry — single source of truth. + * + * All Server-Sent Event type strings used by the backend (`broadcast()` calls) + * and referenced by the frontend (`SSE_EVENTS` in `constants.js`). + * Both files MUST be kept in sync. + * + * ~90 event constants organized by category: + * - **Core** (1): init + * - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ... + * - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ... + * - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate + * - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded + * - **Tasks** (4): created, completed, failed, updated + * - **Mux** (4): created, killed, died, statsUpdated + * - **Respawn** (17): stateChanged, cycleStarted, aiCheck*, timer*, log, ... + * - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed + * - **Scheduled** (6): created, updated, completed, stopped, log, deleted + * - **Teams** (4): created, updated, removed, taskUpdated + * - **Transcript** (4): complete, plan_mode, tool_start, tool_end + * - **Plan orchestration** (5): started, progress, subagent, completed, cancelled + * - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed + * - **Image** (1): detected + * - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed + * - **Cases** (2): created, linked + * + * Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`) + * + * Key export: `SseEvent` namespace object — import for destructured access. + * + * Usage: + * import { SseEvent } from './sse-events.js'; + * ctx.broadcast(SseEvent.SessionCreated, { id: session.id }); + * + * When adding a new event: + * 1. Add the constant here with JSDoc + * 2. Add the matching entry in `src/web/public/constants.js` SSE_EVENTS object + * 3. Add the frontend listener in the appropriate `addListener()` call + */ + +// ─── Core ──────────────────────────────────────────────────────────────────── + +/** Sent to each SSE client on initial connection with full app state. */ +export const Init = 'init' as const; + +// ─── Session Lifecycle ─────────────────────────────────────────────────────── + +/** New session spawned. */ +export const SessionCreated = 'session:created' as const; +/** Session state changed (status, config, tokens, etc.). */ +export const SessionUpdated = 'session:updated' as const; +/** Session permanently removed. */ +export const SessionDeleted = 'session:deleted' as const; +/** Raw PTY terminal output chunk. */ +export const SessionTerminal = 'session:terminal' as const; +/** Client should re-fetch the full terminal buffer (e.g. after reconnect). */ +export const SessionNeedsRefresh = 'session:needsRefresh' as const; +/** Terminal buffer cleared (e.g. /clear command). */ +export const SessionClearTerminal = 'session:clearTerminal' as const; +/** Claude finished a prompt — includes result and cost. */ +export const SessionCompletion = 'session:completion' as const; +/** Session-level error. */ +export const SessionError = 'session:error' as const; +/** Claude CLI process exited. */ +export const SessionExit = 'session:exit' as const; +/** Session transitioned to idle (waiting for input). */ +export const SessionIdle = 'session:idle' as const; +/** Session transitioned to working (Claude is processing). */ +export const SessionWorking = 'session:working' as const; +/** Auto-clear triggered for the session. */ +export const SessionAutoClear = 'session:autoClear' as const; +/** Auto-compact triggered for the session. */ +export const SessionAutoCompact = 'session:autoCompact' as const; +/** CLI version/model info detected from session output. */ +export const SessionCliInfo = 'session:cliInfo' as const; +/** General session message (e.g. status text). */ +export const SessionMessage = 'session:message' as const; +/** Session entered interactive mode (claude or shell). */ +export const SessionInteractive = 'session:interactive' as const; +/** Prompt sent to session for execution. */ +export const SessionRunning = 'session:running' as const; + +// ─── Session: Ralph ────────────────────────────────────────────────────────── + +/** Ralph loop state changed (enabled/disabled, iteration count). */ +export const SessionRalphLoopUpdate = 'session:ralphLoopUpdate' as const; +/** Ralph todo items updated. */ +export const SessionRalphTodoUpdate = 'session:ralphTodoUpdate' as const; +/** Ralph completion phrase detected in output. */ +export const SessionRalphCompletionDetected = 'session:ralphCompletionDetected' as const; +/** Ralph status block parsed from output. */ +export const SessionRalphStatusUpdate = 'session:ralphStatusUpdate' as const; +/** Circuit breaker state changed (CLOSED/HALF_OPEN/OPEN). */ +export const SessionCircuitBreakerUpdate = 'session:circuitBreakerUpdate' as const; +/** Exit gate condition met (e.g. completion phrase found). */ +export const SessionExitGateMet = 'session:exitGateMet' as const; + +// ─── Session: Bash Tools ───────────────────────────────────────────────────── + +/** Bash tool invocation started. */ +export const SessionBashToolStart = 'session:bashToolStart' as const; +/** Bash tool invocation completed. */ +export const SessionBashToolEnd = 'session:bashToolEnd' as const; +/** Active bash tools list changed. */ +export const SessionBashToolsUpdate = 'session:bashToolsUpdate' as const; + +// ─── Session: Plan ─────────────────────────────────────────────────────────── + +/** Plan task status updated. */ +export const SessionPlanTaskUpdate = 'session:planTaskUpdate' as const; +/** Plan checkpoint created. */ +export const SessionPlanCheckpoint = 'session:planCheckpoint' as const; +/** Plan rolled back to a previous version. */ +export const SessionPlanRollback = 'session:planRollback' as const; +/** New task added to plan. */ +export const SessionPlanTaskAdded = 'session:planTaskAdded' as const; + +// ─── Tasks ─────────────────────────────────────────────────────────────────── + +/** Background task created. */ +export const TaskCreated = 'task:created' as const; +/** Background task completed successfully. */ +export const TaskCompleted = 'task:completed' as const; +/** Background task failed. */ +export const TaskFailed = 'task:failed' as const; +/** Background task state updated. */ +export const TaskUpdated = 'task:updated' as const; + +// ─── Mux (tmux) ────────────────────────────────────────────────────────────── + +/** tmux session created. */ +export const MuxCreated = 'mux:created' as const; +/** tmux session killed. */ +export const MuxKilled = 'mux:killed' as const; +/** tmux session died unexpectedly. */ +export const MuxDied = 'mux:died' as const; +/** tmux session stats refreshed. */ +export const MuxStatsUpdated = 'mux:statsUpdated' as const; + +// ─── Respawn ───────────────────────────────────────────────────────────────── + +/** Respawn loop started for a session. */ +export const RespawnStarted = 'respawn:started' as const; +/** Respawn loop stopped. */ +export const RespawnStopped = 'respawn:stopped' as const; +/** Respawn state machine transitioned. */ +export const RespawnStateChanged = 'respawn:stateChanged' as const; +/** New respawn cycle started. */ +export const RespawnCycleStarted = 'respawn:cycleStarted' as const; +/** Respawn cycle completed. */ +export const RespawnCycleCompleted = 'respawn:cycleCompleted' as const; +/** Respawn blocked (e.g. by circuit breaker or active teammates). */ +export const RespawnBlocked = 'respawn:blocked' as const; +/** Respawn step sent to session (update prompt, clear, kickstart). */ +export const RespawnStepSent = 'respawn:stepSent' as const; +/** Respawn step completed. */ +export const RespawnStepCompleted = 'respawn:stepCompleted' as const; +/** Idle/completion detection status updated. */ +export const RespawnDetectionUpdate = 'respawn:detectionUpdate' as const; +/** Auto-accept sent for permission prompt. */ +export const RespawnAutoAcceptSent = 'respawn:autoAcceptSent' as const; +/** AI idle check started. */ +export const RespawnAiCheckStarted = 'respawn:aiCheckStarted' as const; +/** AI idle check completed with result. */ +export const RespawnAiCheckCompleted = 'respawn:aiCheckCompleted' as const; +/** AI idle check failed. */ +export const RespawnAiCheckFailed = 'respawn:aiCheckFailed' as const; +/** AI check cooldown state changed. */ +export const RespawnAiCheckCooldown = 'respawn:aiCheckCooldown' as const; +/** Plan completion check started. */ +export const RespawnPlanCheckStarted = 'respawn:planCheckStarted' as const; +/** Plan completion check completed with result. */ +export const RespawnPlanCheckCompleted = 'respawn:planCheckCompleted' as const; +/** Plan completion check failed. */ +export const RespawnPlanCheckFailed = 'respawn:planCheckFailed' as const; +/** Respawn timer started (idle, duration, etc.). */ +export const RespawnTimerStarted = 'respawn:timerStarted' as const; +/** Respawn timer cancelled. */ +export const RespawnTimerCancelled = 'respawn:timerCancelled' as const; +/** Respawn timer completed. */ +export const RespawnTimerCompleted = 'respawn:timerCompleted' as const; +/** Respawn action logged (for monitor UI). */ +export const RespawnActionLog = 'respawn:actionLog' as const; +/** Respawn debug log message. */ +export const RespawnLog = 'respawn:log' as const; +/** Respawn error occurred. */ +export const RespawnError = 'respawn:error' as const; +/** Respawn configuration updated. */ +export const RespawnConfigUpdated = 'respawn:configUpdated' as const; + +// ─── Subagents ─────────────────────────────────────────────────────────────── + +/** New subagent (background agent) discovered. */ +export const SubagentDiscovered = 'subagent:discovered' as const; +/** Subagent state updated. */ +export const SubagentUpdated = 'subagent:updated' as const; +/** Subagent tool call detected. */ +export const SubagentToolCall = 'subagent:tool_call' as const; +/** Subagent tool result received. */ +export const SubagentToolResult = 'subagent:tool_result' as const; +/** Subagent progress update. */ +export const SubagentProgress = 'subagent:progress' as const; +/** Subagent message (assistant text). */ +export const SubagentMessage = 'subagent:message' as const; +/** Subagent finished. */ +export const SubagentCompleted = 'subagent:completed' as const; + +// ─── Scheduled Runs ────────────────────────────────────────────────────────── + +/** Scheduled run created. */ +export const ScheduledCreated = 'scheduled:created' as const; +/** Scheduled run state updated. */ +export const ScheduledUpdated = 'scheduled:updated' as const; +/** Scheduled run completed. */ +export const ScheduledCompleted = 'scheduled:completed' as const; +/** Scheduled run stopped. */ +export const ScheduledStopped = 'scheduled:stopped' as const; +/** Scheduled run log entry added. */ +export const ScheduledLog = 'scheduled:log' as const; +/** Scheduled run deleted. */ +export const ScheduledDeleted = 'scheduled:deleted' as const; + +// ─── Teams ─────────────────────────────────────────────────────────────────── + +/** Agent team created. */ +export const TeamCreated = 'team:created' as const; +/** Agent team config updated (e.g. new member joined). */ +export const TeamUpdated = 'team:updated' as const; +/** Agent team removed. */ +export const TeamRemoved = 'team:removed' as const; +/** Agent team task updated. */ +export const TeamTaskUpdated = 'team:taskUpdated' as const; + +// ─── Transcript ────────────────────────────────────────────────────────────── + +/** Transcript complete event detected. */ +export const TranscriptComplete = 'transcript:complete' as const; +/** Plan mode detected in transcript. */ +export const TranscriptPlanMode = 'transcript:plan_mode' as const; +/** Tool invocation started in transcript. */ +export const TranscriptToolStart = 'transcript:tool_start' as const; +/** Tool invocation ended in transcript. */ +export const TranscriptToolEnd = 'transcript:tool_end' as const; + +// ─── Plan Orchestration ────────────────────────────────────────────────────── + +/** Plan generation started. */ +export const PlanStarted = 'plan:started' as const; +/** Plan generation progress update. */ +export const PlanProgress = 'plan:progress' as const; +/** Plan subagent event (research or planner agent). */ +export const PlanSubagent = 'plan:subagent' as const; +/** Plan generation completed. */ +export const PlanCompleted = 'plan:completed' as const; +/** Plan generation cancelled. */ +export const PlanCancelled = 'plan:cancelled' as const; + +// ─── Tunnel ────────────────────────────────────────────────────────────────── + +/** Cloudflare tunnel started. */ +export const TunnelStarted = 'tunnel:started' as const; +/** Cloudflare tunnel stopped. */ +export const TunnelStopped = 'tunnel:stopped' as const; +/** Tunnel startup progress. */ +export const TunnelProgress = 'tunnel:progress' as const; +/** Tunnel error. */ +export const TunnelError = 'tunnel:error' as const; +/** QR code rotated (new token generated). */ +export const TunnelQrRotated = 'tunnel:qrRotated' as const; +/** QR code force-regenerated. */ +export const TunnelQrRegenerated = 'tunnel:qrRegenerated' as const; +/** QR auth token consumed by a client. */ +export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const; + +// ─── Image ─────────────────────────────────────────────────────────────────── + +/** New image file detected (e.g. screenshot upload). */ +export const ImageDetected = 'image:detected' as const; + +// ─── Hooks ─────────────────────────────────────────────────────────────────── + +/** Claude Code hook: session idle, waiting for input. */ +export const HookIdlePrompt = 'hook:idle_prompt' as const; +/** Claude Code hook: tool requesting permission. */ +export const HookPermissionPrompt = 'hook:permission_prompt' as const; +/** Claude Code hook: elicitation dialog (Claude asking a question). */ +export const HookElicitationDialog = 'hook:elicitation_dialog' as const; +/** Claude Code hook: response complete. */ +export const HookStop = 'hook:stop' as const; +/** Claude Code hook: teammate went idle. */ +export const HookTeammateIdle = 'hook:teammate_idle' as const; +/** Claude Code hook: teammate task completed. */ +export const HookTaskCompleted = 'hook:task_completed' as const; + +// ─── Cases ─────────────────────────────────────────────────────────────────── + +/** New case directory created. */ +export const CaseCreated = 'case:created' as const; +/** Existing directory linked as a case. */ +export const CaseLinked = 'case:linked' as const; + +// ─── Namespace Re-export ───────────────────────────────────────────────────── + +/** + * All SSE event types as a namespace object. + * Convenient for destructured imports or passing as a group. + */ +export const SseEvent = { + // Core + Init, + + // Session lifecycle + SessionCreated, + SessionUpdated, + SessionDeleted, + SessionTerminal, + SessionNeedsRefresh, + SessionClearTerminal, + SessionCompletion, + SessionError, + SessionExit, + SessionIdle, + SessionWorking, + SessionAutoClear, + SessionAutoCompact, + SessionCliInfo, + SessionMessage, + SessionInteractive, + SessionRunning, + + // Session: Ralph + SessionRalphLoopUpdate, + SessionRalphTodoUpdate, + SessionRalphCompletionDetected, + SessionRalphStatusUpdate, + SessionCircuitBreakerUpdate, + SessionExitGateMet, + + // Session: Bash tools + SessionBashToolStart, + SessionBashToolEnd, + SessionBashToolsUpdate, + + // Session: Plan + SessionPlanTaskUpdate, + SessionPlanCheckpoint, + SessionPlanRollback, + SessionPlanTaskAdded, + + // Tasks + TaskCreated, + TaskCompleted, + TaskFailed, + TaskUpdated, + + // Mux + MuxCreated, + MuxKilled, + MuxDied, + MuxStatsUpdated, + + // Respawn + RespawnStarted, + RespawnStopped, + RespawnStateChanged, + RespawnCycleStarted, + RespawnCycleCompleted, + RespawnBlocked, + RespawnStepSent, + RespawnStepCompleted, + RespawnDetectionUpdate, + RespawnAutoAcceptSent, + RespawnAiCheckStarted, + RespawnAiCheckCompleted, + RespawnAiCheckFailed, + RespawnAiCheckCooldown, + RespawnPlanCheckStarted, + RespawnPlanCheckCompleted, + RespawnPlanCheckFailed, + RespawnTimerStarted, + RespawnTimerCancelled, + RespawnTimerCompleted, + RespawnActionLog, + RespawnLog, + RespawnError, + RespawnConfigUpdated, + + // Subagents + SubagentDiscovered, + SubagentUpdated, + SubagentToolCall, + SubagentToolResult, + SubagentProgress, + SubagentMessage, + SubagentCompleted, + + // Scheduled runs + ScheduledCreated, + ScheduledUpdated, + ScheduledCompleted, + ScheduledStopped, + ScheduledLog, + ScheduledDeleted, + + // Teams + TeamCreated, + TeamUpdated, + TeamRemoved, + TeamTaskUpdated, + + // Transcript + TranscriptComplete, + TranscriptPlanMode, + TranscriptToolStart, + TranscriptToolEnd, + + // Plan orchestration + PlanStarted, + PlanProgress, + PlanSubagent, + PlanCompleted, + PlanCancelled, + + // Tunnel + TunnelStarted, + TunnelStopped, + TunnelProgress, + TunnelError, + TunnelQrRotated, + TunnelQrRegenerated, + TunnelQrAuthUsed, + + // Image + ImageDetected, + + // Hooks + HookIdlePrompt, + HookPermissionPrompt, + HookElicitationDialog, + HookStop, + HookTeammateIdle, + HookTaskCompleted, + + // Cases + CaseCreated, + CaseLinked, +} as const;