7.**Verify the dev server starts**: After each task, run `npx tsx src/index.ts web --port 3099 &` on a non-production port, confirm `curl -s http://localhost:3099/api/status | jq .status` returns `"ok"`, then kill the background process. This is essential — there are zero server.ts tests.
---
## Goal
Reduce `src/web/server.ts` from ~6,710 LOC to ~1,500 LOC by extracting route handlers into domain-specific route modules, auth logic into middleware, and SSE/batching into service modules. The WebServer class retains orchestration (start/stop, session lifecycle, listener wiring) but delegates all HTTP route definitions to separate files.
│ ├── mux-routes.ts (~80 LOC — list, delete, reconcile, stats)
│ ├── team-routes.ts (~50 LOC — list teams, team tasks)
│ └── scheduled-routes.ts (~120 LOC — CRUD for scheduled runs, quick-start, quick-run)
└── schemas.ts (existing — unchanged)
```
---
## Key Design Decision: RouteContext Pattern
Routes need access to server state (sessions, respawn controllers, store, mux, etc.) without importing WebServer directly. We use a **context object pattern** where the WebServer exposes a `RouteContext` interface that route modules receive.
This avoids:
- Circular dependencies (routes importing server, server importing routes)
- Exposing all 40+ private WebServer fields
- Making route modules aware of server internals
Each route module exports a `register(app, ctx)` function that Fastify calls during setup.
### Why NOT Fastify plugins/decorators
Fastify plugins with `fastify.decorate()` would work but:
- Requires TypeScript module augmentation for type safety (brittle)
- Each decorator is on the Fastify instance, not a typed interface — easy to misspell
- The context pattern is simpler, standard in large Fastify apps, and plays well with `strictNullChecks`
---
## Task 1: Create RouteContext Interface
**Files to create**: `src/web/route-context.ts`
**Files to edit**: `src/web/server.ts`
**Time**: ~2 hours
### Problem
Route modules need access to server state (sessions, controllers, store) and helper methods (broadcast, persistSessionState, cleanupSession). A typed interface provides this without coupling routes to WebServer internals.
### Implementation
Create `src/web/route-context.ts` that defines the interface:
```typescript
/**
* @fileoverview Shared context interface for route modules.
*
* Route handlers receive a RouteContext to access server state and
* helper methods without directly depending on the WebServer class.
**Use `findSessionOrFail` (throw) consistently for session lookups.** For other not-found patterns (scheduled runs, subagents, etc.), continue using the existing early-return pattern (`if (!x) return createErrorResponse(...)`). This avoids a full rewrite of all error handling while still eliminating the most common repetition.
Do NOT mix thrown and returned errors within the same route handler — pick one per handler. `findSessionOrFail` at the top of a handler (throw), then early-returns for everything else.
### Module-Level Singletons
Two singletons are imported at the module level in `server.ts` (not on `this`) and used directly by route handlers:
| Singleton | Import | Used By Routes |
|-----------|--------|---------------|
| `imageWatcher` | `import { imageWatcher } from '../image-watcher.js'` | POST /api/sessions/:id/image-watcher, PUT /api/settings |
| `fileStreamManager` | `import { fileStreamManager } from '../file-stream-manager.js'` | GET /api/sessions/:id/tail-file, DELETE /api/sessions/:id/tail-file/:streamId |
**Convention**: Route modules should import these singletons directly (not via RouteContext). They are already module-level singletons with no `this` binding, so direct import is simpler and consistent with their existing usage.
The `ScheduledRun` type is currently defined locally in `server.ts` (around line 131-143). Move it to `route-context.ts` or a shared types location since route modules need it.
Auth logic (HTTP Basic Auth, session cookies, rate limiting) is inline in `setupRoutes()` at lines 659-780. This is ~120 LOC of self-contained logic that doesn't depend on any route handlers.
### Implementation
Extract the `onRequest` hook registration into a standalone function:
**New file**: `src/web/middleware/auth.ts`
```typescript
/**
* @fileoverview HTTP Basic Auth middleware with session cookies and rate limiting.
*
* Extracted from server.ts setupRoutes() auth section.
* Only active when CODEMAN_PASSWORD environment variable is set.
Also extract the security headers hook (CSP, X-Frame-Options, X-Content-Type-Options, HSTS) from `setupRoutes()` into `auth.ts` as a separate function `registerSecurityHeaders(app, https)`, since it's closely related to the auth/security concern.
### Edit server.ts
In `setupRoutes()`, replace the inline auth block (~lines 659-780) with:
Move the auth constants (`AUTH_SESSION_TTL_MS`, `MAX_AUTH_SESSIONS`, `AUTH_FAILURE_WINDOW_MS`, `AUTH_FAILURE_MAX`, `AUTH_COOKIE_NAME`) from server.ts to the middleware file.
### Verification
```bash
tsc --noEmit
npm run lint
# Start server and verify auth still works:
CODEMAN_PASSWORD=test npx tsx src/index.ts web --port 3099&
**File to create**: `src/web/routes/session-routes.ts`
**File to edit**: `src/web/server.ts`
**Time**: ~3 hours (largest route group)
### Problem
Session routes are the largest group (~30 handlers, ~800 LOC) covering CRUD, input, resize, terminal buffer access, auto-clear/compact, and image watcher toggling.
### Routes to Extract
| Method | Path | Current Line | Purpose |
|--------|------|-------------|---------|
| GET | `/api/sessions` | 1015 | List all sessions (cached) |
| POST | `/api/sessions` | 1017 | Create session |
| PUT | `/api/sessions/:id/name` | 1094 | Rename session |
| PUT | `/api/sessions/:id/color` | 1117 | Change session color |
The `POST /api/sessions` handler at line 1017 is ~75 LOC and does complex work (spawn PTY, setup listeners, persist state, broadcast). It uses several internal methods. Consider extracting the create logic into a `createSession()` method on the RouteContext rather than inlining all of it in the route module.
### Verification
```bash
tsc --noEmit
npm run lint
# Verify session CRUD works:
npx tsx src/index.ts web --port 3099&
sleep 3
# Create session:
curl -s -X POST http://localhost:3099/api/sessions -H 'Content-Type: application/json'\
-`ctx.setupRespawnListeners()` — wire events for new controllers
-`ctx.setupTimedRespawn()` — set duration timer
-`ctx.saveRespawnConfig()` — persist to mux metadata
-`ctx.persistSessionState()` — update state.json
-`ctx.broadcast()` — SSE events
-`RespawnController` constructor — instantiated in start/interactive-respawn routes
### Respawn Start Route Complexity
The `POST /api/sessions/:id/respawn/start` handler (line 2175, ~45 LOC) creates a new `RespawnController`, calls `setupRespawnListeners`, starts it, and optionally sets up timed respawn. This is complex but self-contained — it can move to the route module as-is, with `ctx.setupRespawnListeners()` and `ctx.setupTimedRespawn()` as the bridge back to server.ts.
### Interactive Respawn Complexity
`POST /api/sessions/:id/interactive-respawn` (line 2313, ~75 LOC) is the most complex respawn route. It stops existing controllers, creates a new one with different config, and handles the "sendInit" flow. All logic can move to the route module since it only needs `ctx` methods.
### Verification
```bash
tsc --noEmit
npm run lint
npx vitest run test/respawn-controller.test.ts # Ensure respawn logic still works
```
---
## Task 5: Extract Subagent, Mux, and Team Routes
**File to create**: `src/web/routes/subagent-routes.ts`, `src/web/routes/mux-routes.ts`, `src/web/routes/team-routes.ts`
**File to edit**: `src/web/server.ts`
**Time**: ~2 hours
### Subagent Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/subagents` | 4271 | List all subagents |
| GET | `/api/sessions/:id/subagents` | 4280 | List session subagents |
| GET | `/api/subagents/:agentId` | 4291 | Get single subagent |
| GET | `/api/subagents/:agentId/transcript` | 4301 | Get transcript |
| POST | `/api/sessions/:id/plan/checkpoint` | 3909 | Create plan checkpoint |
| GET | `/api/sessions/:id/plan/history` | 3927 | Get plan history |
| POST | `/api/sessions/:id/plan/rollback/:version` | 3943 | Rollback plan |
| POST | `/api/sessions/:id/plan/task` | 3965 | Add plan task |
These routes use `ctx.activePlanOrchestrators`, `PlanOrchestrator` constructor, and `ctx.broadcast()`. The generate-plan handlers are the most complex (~200 LOC each) because they set up orchestrator event listeners and manage the async plan generation lifecycle.
### Case Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/cases` | 2676 | List cases |
| POST | `/api/cases` | 2718 | Create case |
| POST | `/api/cases/link` | 2760 | Link existing dir as case |
| GET | `/api/cases/:name` | 2818 | Get case details |
| GET | `/api/cases/:name/fix-plan` | 2853 | Get case fix plan |
Case routes use filesystem operations (`existsSync`, `mkdirSync`, `readFileSync`, `writeFileSync`) and the `generateClaudeMd` template function. They're self-contained — the only ctx dependency is `ctx.store` for the cases directory path.
### Ralph Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| POST | `/api/sessions/:id/ralph-config` | 1653 | Update Ralph config |
| GET | `/api/sessions/:id/ralph-status` | 1746 | Get Ralph status |
| GET | `/api/sessions/:id/fix-plan` | 1766 | Get fix plan |
| POST | `/api/sessions/:id/fix-plan/import` | 1785 | Import fix plan |
| POST | `/api/sessions/:id/fix-plan/write` | 1811 | Write fix plan tasks |
| POST | `/api/sessions/:id/fix-plan/read` | 1842 | Trigger fix plan re-read |
| POST | `/api/sessions/:id/ralph-prompt/write` | 1880 | Write Ralph prompt file |
| POST | `/api/ralph-loop/start` | 3128 | Start Ralph Loop |
Ralph routes access `session.ralphTracker` methods and `ctx.respawnControllers` for circuit breaker operations. The Ralph Loop start route (line 3128, ~250 LOC) is the most complex — it creates sessions, sets up Ralph Loop mode, and configures respawn.
| GET | `/api/settings` | 3991 | Get global settings |
| PUT | `/api/settings` | 4003 | Update global settings |
| GET | `/api/execution/model-config` | 4074 | Get model config |
| PUT | `/api/execution/model-config` | 4087 | Update model config |
The `getLightState()` and `getLightSessionsState()` cached methods remain in server.ts (they access the cache fields) and are exposed via RouteContext. The system routes just call them.
The `getSystemStats()` helper (lines 4710-4753) moves to `system-routes.ts` since it has no server state dependencies (only `os` module calls).
### File Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/sessions/:id/files` | 1260 | File tree browser |
| POST | `/api/screenshots` | 4455 | Upload screenshot |
| GET | `/api/screenshots` | 4542 | List screenshots |
| GET | `/api/screenshots/:name` | 4556 | Serve screenshot |
File routes are the most self-contained group — they use `fs` operations, path validation, and `fileStreamManager`. The only ctx dependencies are `ctx.sessions` (to verify session exists and get working dir) and `ctx.broadcast()` (for screenshot upload notification).
**Module-level singletons**: `fileStreamManager` is imported directly from `'../file-stream-manager.js'` — import it directly in `file-routes.ts`, not via RouteContext. Similarly, the image watcher toggle route in session-routes uses `imageWatcher` from `'../image-watcher.js'` — import directly.
Quick-start (line 2965, ~160 LOC) is complex: it creates a case directory, writes CLAUDE.md, configures hooks, creates a session, and starts respawn. It uses many ctx methods. Consider keeping it intact as a single large handler in the route module.
### Hook Event Route
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| POST | `/api/hook-event` | 4357 | Receive Claude Code hook events |
This route is special: it's exempt from auth (localhost-only), validates with `HookEventSchema`, and broadcasts `hook:{eventName}` events. It also handles Web Push notifications via `ctx.sendPushNotifications()`. Place it in `system-routes.ts` or its own `hook-routes.ts`.
The SSE endpoint (line 803, ~30 LOC) sets up headers, adds the client to `sseClients`, sends initial state, and handles cleanup. This can stay in server.ts since it directly manages the SSE client set, or move to system-routes with `sseClients` exposed via RouteContext.
**Recommendation**: Keep the SSE endpoint in server.ts — it's tightly coupled to the broadcast infrastructure and only ~30 LOC.
1. **Remove all extracted route handlers** from `setupRoutes()` — after all route modules are imported and registered, the remaining inline routes should be zero (except SSE and sw.js).
2. **Remove unused imports** — many imports at the top of server.ts were only used by route handlers (e.g., `generateClaudeMd`, `parseRalphLoopConfig`). Delete them.
3. **Move constants** that only route modules use to the route modules. Constants used by server core (batching intervals, cache TTLs) stay.
4. **Remove the `ScheduledRun` type** from server.ts if it was moved to route-context.ts in Task 1.
5. **Verify no dead code** remains — run `tsc --noEmit` with strict unused-variable checking.
### Verification (Comprehensive)
```bash
# 1. TypeScript
tsc --noEmit
# 2. Lint + format
npm run lint
npm run format:check
# 3. Line count verification
wc -l src/web/server.ts
# Should be ~1,300-1,500 LOC
# 4. Route count verification (should match original ~110)
| `this` binding lost when methods passed via RouteContext | Medium | High (runtime crash) | Use `.bind(this)` in `createRouteContext()` for all methods |
| Circular dependency between server.ts and route modules | Low | High (import crash) | Route modules only import `route-context.ts`, never `server.ts` |
| Route handler accesses private field not in RouteContext | High | Medium (compile error) | Audit each route's `this.xxx` usage before moving; add to RouteContext as needed |
| Auth middleware ordering changes | Low | High (security) | Keep `addHook('onRequest')` registration before all routes |
| SSE endpoint moved loses access to sseClients | Low | Medium | Keep SSE endpoint in server.ts |
| Forgotten import after extraction | Medium | Low (compile error) | `tsc --noEmit` catches immediately |
| Performance regression from context indirection | Very Low | Low | Context is a plain object; zero overhead vs `this.xxx` |