Files
Codeman/docs/phase3-implementation-plan.md
T
arkonandClaude Opus 4.6 e05d507254 refactor: extract server.ts routes into domain modules (phase 3)
Split 6,710-line server.ts into focused route modules using port
interfaces for dependency injection. 107/109 routes extracted into
12 domain files with auth middleware, 5 port interfaces, and shared
helpers. Server.ts retains orchestration (SSE, lifecycle, state).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:12:45 +01:00

1098 lines
46 KiB
Markdown

# Phase 3 Implementation Plan: server.ts Route Extraction
**Source**: `docs/code-structure-findings.md` (Phase 3 — server.ts Route Extraction)
**Estimated effort**: 3-4 days
**Tasks**: 8 tasks with dependencies (see dependency graph below)
---
## Safety Constraints
Before starting ANY work, read and follow these rules:
1. **Never run `npx vitest run`** (full suite) — it kills tmux sessions. You are running inside a Codeman-managed tmux session.
2. **Run individual tests only**: `npx vitest run test/<file>.test.ts`
3. **Never test on port 3000** — the live dev server runs there. Tests use ports 3150+.
4. **After TypeScript changes**: Run `tsc --noEmit` to verify type checking passes.
5. **Before considering done**: Run `npm run lint` and `npm run format:check` to ensure CI passes.
6. **Never kill tmux sessions** — check `echo $CODEMAN_MUX` first.
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.
---
## Task Dependencies
```
Task 1 (RouteContext interface)
├──> Task 2 (Auth middleware)
├──> Task 3 (Session routes)
├──> Task 4 (Respawn routes)
├──> Task 5 (Subagent + mux + team routes)
├──> Task 6 (Plan + case + ralph routes)
├──> Task 7 (System + settings + push + misc routes)
└──> Task 8 (Final cleanup — reduce server.ts)
```
**Task 1** must complete first — it defines the shared interface all route modules use to access server state.
**Tasks 2-7** are independent and can run in parallel.
**Task 8** depends on all prior tasks.
---
## Target Directory Structure
```
src/web/
├── server.ts (~1,500 LOC — orchestration, start/stop, listeners, SSE infra)
├── route-context.ts (~80 LOC — RouteContext interface + helpers)
├── middleware/
│ └── auth.ts (~120 LOC — Basic Auth + session cookies + rate limiting)
├── routes/
│ ├── session-routes.ts (~800 LOC — CRUD, input, resize, terminal buffer, auto-ops)
│ ├── respawn-routes.ts (~400 LOC — status, config, start/stop, enable, interactive)
│ ├── subagent-routes.ts (~250 LOC — list, transcript, kill, cleanup, window states)
│ ├── plan-routes.ts (~500 LOC — generate, detailed, cancel, tasks, checkpoint, rollback)
│ ├── case-routes.ts (~400 LOC — CRUD, link, fix-plan, ralph wizard)
│ ├── ralph-routes.ts (~450 LOC — config, status, circuit-breaker, fix-plan, prompts, loop start)
│ ├── system-routes.ts (~350 LOC — status, stats, config, debug, lifecycle, settings, model)
│ ├── file-routes.ts (~400 LOC — file tree, preview, raw, tail, screenshots)
│ ├── push-routes.ts (~100 LOC — VAPID key, subscribe, update prefs, unsubscribe)
│ ├── 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.
*
* @module web/route-context
*/
import type { FastifyInstance } from 'fastify';
import type { Session } from '../session.js';
import type { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import type { StateStore } from '../state-store.js';
import type { PlanOrchestrator } from '../plan-orchestrator.js';
import type { RunSummaryTracker } from '../run-summary.js';
import type { TranscriptWatcher } from '../transcript-watcher.js';
import type { TeamWatcher } from '../team-watcher.js';
import type { TunnelManager } from '../tunnel-manager.js';
import type { PushSubscriptionStore } from '../push-store.js';
import type {
ApiErrorCode,
ApiResponse,
PersistedRespawnConfig,
NiceConfig,
} from '../types.js';
/**
* Context object passed to route modules.
* Provides access to server state and helper methods.
*/
export interface RouteContext {
// === Core State ===
readonly sessions: Map<string, Session>;
readonly respawnControllers: Map<string, RespawnController>;
readonly respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }>;
readonly runSummaryTrackers: Map<string, RunSummaryTracker>;
readonly activePlanOrchestrators: Map<string, PlanOrchestrator>;
readonly scheduledRuns: Map<string, ScheduledRun>; // Type imported from server.ts or moved
readonly store: StateStore;
readonly mux: TerminalMultiplexer;
readonly teamWatcher: TeamWatcher;
readonly tunnelManager: TunnelManager;
readonly pushStore: PushSubscriptionStore;
// === Config ===
readonly port: number;
readonly https: boolean;
readonly testMode: boolean;
readonly serverStartTime: number;
// === Methods ===
/** Broadcast an SSE event to all connected clients */
broadcast(event: string, data: unknown): void;
/** Debounced session state persistence */
persistSessionState(session: Session): void;
/** Immediate session state persistence */
persistSessionStateNow(session: Session): void;
/** Clean up all resources for a session */
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
/** Set up event listeners for a session */
setupSessionListeners(session: Session): void;
/** Remove all event listeners for a session */
removeSessionListeners(sessionId: string): void;
/** Set up respawn controller event listeners */
setupRespawnListeners(sessionId: string, controller: RespawnController): void;
/** Set up a timed respawn duration */
setupTimedRespawn(sessionId: string, durationMinutes: number): void;
/** Restore a respawn controller from persisted config */
restoreRespawnController(session: Session, config: PersistedRespawnConfig, source: string): void;
/** Save respawn config to mux metadata */
saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void;
/** Start transcript watcher for a session */
startTranscriptWatcher(sessionId: string, transcriptPath: string): void;
/** Stop transcript watcher for a session */
stopTranscriptWatcher(sessionId: string): void;
/** Get session state enriched with respawn info */
getSessionStateWithRespawn(session: Session): unknown;
/** Get default CLAUDE.md template path from settings */
getDefaultClaudeMdPath(): Promise<string | undefined>;
/** Batch terminal data for 60fps streaming */
batchTerminalData(sessionId: string, data: string): void;
/** Broadcast debounced session state update */
broadcastSessionStateDebounced(sessionId: string): void;
/** Batch a task update for SSE broadcasting */
batchTaskUpdate(sessionId: string, task: unknown): void;
// === Config Getters (used by session create, quick-start, ralph-loop, plan routes) ===
/** Get global nice/ionice config from settings */
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
/** Get model config (defaultModel, etc.) from settings */
getModelConfig(): Promise<{ defaultModel?: string } | undefined>;
/** Get Claude mode config (claudeMode, allowedTools) from settings */
getClaudeModeConfig(): Promise<{ claudeMode?: string; allowedTools?: string[] }>;
// === Scheduling (used by scheduled-routes) ===
/** Start a scheduled run (creates session, runs prompt, manages lifecycle) */
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun>;
/** Stop a scheduled run by ID */
stopScheduledRun(id: string): Promise<void>;
// === Notifications (used by hook-event route) ===
/** Send push notifications to all subscribed clients */
sendPushNotifications(event: string, data: Record<string, unknown>): void;
}
/**
* Signature for a route registration function.
* Each route module exports a function with this signature.
*/
export type RegisterRoutes = (app: FastifyInstance, ctx: RouteContext) => void;
```
### Expose context from WebServer
In `server.ts`, add a private method that creates the context object. This is called once during `setupRoutes()` and passed to each route module:
```typescript
private createRouteContext(): RouteContext {
return {
sessions: this.sessions,
respawnControllers: this.respawnControllers,
respawnTimers: this.respawnTimers,
runSummaryTrackers: this.runSummaryTrackers,
activePlanOrchestrators: this.activePlanOrchestrators,
scheduledRuns: this.scheduledRuns,
store: this.store,
mux: this.mux,
teamWatcher: this.teamWatcher,
tunnelManager: this.tunnelManager,
pushStore: this.pushStore,
port: this.port,
https: this.https,
testMode: this.testMode,
serverStartTime: this.serverStartTime,
broadcast: this.broadcast.bind(this),
persistSessionState: this.persistSessionState.bind(this),
persistSessionStateNow: this._persistSessionStateNow.bind(this),
cleanupSession: this.cleanupSession.bind(this),
setupSessionListeners: this.setupSessionListeners.bind(this),
removeSessionListeners: this.removeSessionListeners.bind(this),
setupRespawnListeners: this.setupRespawnListeners.bind(this),
setupTimedRespawn: this.setupTimedRespawn.bind(this),
restoreRespawnController: this.restoreRespawnController.bind(this),
saveRespawnConfig: this.saveRespawnConfig.bind(this),
startTranscriptWatcher: this.startTranscriptWatcher.bind(this),
stopTranscriptWatcher: this.stopTranscriptWatcher.bind(this),
getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
batchTerminalData: this.batchTerminalData.bind(this),
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
batchTaskUpdate: this.batchTaskUpdate.bind(this),
getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
getModelConfig: this.getModelConfig.bind(this),
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
startScheduledRun: this.startScheduledRun.bind(this),
stopScheduledRun: this.stopScheduledRun.bind(this),
sendPushNotifications: this.sendPushNotifications.bind(this),
};
}
```
### Helper: findSessionOrFail
Add a shared helper to `route-context.ts` (replaces ~43 repetitions of the session-not-found pattern):
```typescript
import { createErrorResponse, ApiErrorCode } from '../types.js';
/**
* Look up a session by ID, throwing a structured error if not found.
* Route handlers call this to avoid ~43 repetitions of the NOT_FOUND pattern.
*/
export function findSessionOrFail(ctx: RouteContext, sessionId: string): Session {
const session = ctx.sessions.get(sessionId);
if (!session) {
throw Object.assign(
new Error('Session not found'),
{ statusCode: 404, response: createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found') }
);
}
return session;
}
```
In each route module, use it as:
```typescript
const session = findSessionOrFail(ctx, sessionId);
// If we get here, session is guaranteed non-null
```
For Fastify error handling, register a global error handler in `setupRoutes()` that catches these structured errors:
```typescript
this.app.setErrorHandler((error, _req, reply) => {
if ('response' in error && 'statusCode' in error) {
return reply.code((error as any).statusCode).send((error as any).response);
}
reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, error.message));
});
```
### Error Pattern Convention
**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.
### ScheduledRun type
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.
### Verification
```bash
tsc --noEmit
npm run lint
npm run format:check
```
---
## Task 2: Extract Auth Middleware
**File to create**: `src/web/middleware/auth.ts`
**File to edit**: `src/web/server.ts`
**Time**: ~1 hour
### Problem
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.
*
* @module web/middleware/auth
*/
import type { FastifyInstance } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
// Auth configuration constants
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const MAX_AUTH_SESSIONS = 100;
const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const AUTH_FAILURE_MAX = 10;
const AUTH_COOKIE_NAME = 'codeman_session';
/**
* Register HTTP Basic Auth with session cookies and rate limiting.
*
* Does nothing if CODEMAN_PASSWORD is not set.
* Exempts /api/hook-event from localhost (Claude Code hooks curl this).
*/
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): {
authSessions: StaleExpirationMap<string, string> | null;
authFailures: StaleExpirationMap<string, number> | null;
} {
const authPassword = process.env.CODEMAN_PASSWORD;
if (!authPassword) {
return { authSessions: null, authFailures: null };
}
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
const authSessions = new StaleExpirationMap<string, string>({
ttlMs: AUTH_SESSION_TTL_MS,
refreshOnGet: true,
});
const authFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
app.addHook('onRequest', (req, reply, done) => {
// Hook events from localhost bypass auth
if (req.url === '/api/hook-event' && req.method === 'POST') {
const ip = req.ip;
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
done();
return;
}
}
const clientIp = req.ip;
// Rate limit check
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
reply.code(429).send('Too Many Requests — try again later');
return;
}
// Session cookie check
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
done();
return;
}
// Basic Auth header check (timing-safe)
const auth = req.headers.authorization;
const authBuf = Buffer.from(auth ?? '');
const expectedBuf = Buffer.from(expectedHeader);
if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) {
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) {
const oldestKey = authSessions.keys().next().value;
if (oldestKey !== undefined) authSessions.delete(oldestKey);
}
authSessions.set(token, clientIp);
authFailures.delete(clientIp);
reply.setCookie(AUTH_COOKIE_NAME, token, {
httpOnly: true,
secure: https,
sameSite: 'lax',
maxAge: AUTH_SESSION_TTL_MS / 1000,
path: '/',
});
done();
return;
}
// Failed — track and reject
authFailures.set(clientIp, failures + 1);
reply.code(401).header('WWW-Authenticate', 'Basic realm="Codeman"').send('Unauthorized');
});
return { authSessions, authFailures };
}
```
### Security headers
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:
```typescript
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
// In setupRoutes():
const { authSessions, authFailures } = registerAuthMiddleware(this.app, this.https);
this.authSessions = authSessions;
this.authFailures = authFailures;
registerSecurityHeaders(this.app, this.https);
```
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 &
sleep 3
# Should get 401 without credentials:
curl -s -o /dev/null -w "%{http_code}" http://localhost:3099/api/status
# Should get 200 with credentials:
curl -s -u admin:test http://localhost:3099/api/status | jq .status
kill %1
```
---
## Task 3: Extract Session Routes
**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 |
| DELETE | `/api/sessions/:id` | 1141 | Delete single session |
| DELETE | `/api/sessions` | 1155 | Kill all sessions |
| GET | `/api/sessions/:id` | 1169 | Get session details |
| GET | `/api/sessions/:id/output` | 1182 | Get session output text |
| GET | `/api/sessions/:id/ralph-state` | 1201 | Get Ralph tracker state |
| GET | `/api/sessions/:id/run-summary` | 1220 | Get run summary timeline |
| GET | `/api/sessions/:id/active-tools` | 1243 | Get active bash tools |
| POST | `/api/sessions/:id/run` | 1915 | Run a prompt |
| POST | `/api/sessions/:id/interactive` | 1942 | Start interactive mode |
| POST | `/api/sessions/:id/shell` | 1985 | Start shell mode |
| POST | `/api/sessions/:id/input` | 2015 | Send input to session |
| POST | `/api/sessions/:id/resize` | 2059 | Resize terminal |
| GET | `/api/sessions/:id/terminal` | 2087 | Get terminal buffer |
| POST | `/api/sessions/:id/auto-clear` | 2444 | Toggle auto-clear |
| POST | `/api/sessions/:id/auto-compact` | 2473 | Toggle auto-compact |
| POST | `/api/sessions/:id/image-watcher` | 2503 | Toggle image watcher |
| POST | `/api/sessions/:id/flicker-filter` | 2535 | Toggle flicker filter |
| GET | `/api/sessions/:id/cpu-limit` | 4120 | Get CPU limit |
| POST | `/api/sessions/:id/cpu-limit` | 4134 | Set CPU limit |
### Implementation Pattern
```typescript
// src/web/routes/session-routes.ts
import type { FastifyInstance } from 'fastify';
import type { RouteContext } from '../route-context.js';
import { findSessionOrFail } from '../route-context.js';
import { CreateSessionSchema, RunPromptSchema, /* ... */ } from '../schemas.js';
import { createErrorResponse, ApiErrorCode } from '../../types.js';
import { Session } from '../../session.js';
export function registerSessionRoutes(app: FastifyInstance, ctx: RouteContext): void {
app.get('/api/sessions', async () => /* moved from server.ts */);
app.post('/api/sessions', async (req) => {
const data = CreateSessionSchema.parse(req.body);
// ... handler body moved verbatim from server.ts
// Replace `this.sessions` with `ctx.sessions`
// Replace `this.broadcast(...)` with `ctx.broadcast(...)`
// Replace `this.persistSessionState(...)` with `ctx.persistSessionState(...)`
});
// ... remaining routes
}
```
### Migration Strategy for Each Route
1. **Copy** the route handler body from server.ts to the new file
2. **Replace** all `this.xxx` references with `ctx.xxx` equivalents
3. **Replace** inline session lookups with `findSessionOrFail(ctx, id)` where applicable
4. **Import** schemas, types, and utilities used by the handler
5. **Delete** the route from server.ts
6. **Verify** with `tsc --noEmit` after each batch of routes
### State Access Patterns in Session Routes
These routes access WebServer state that must be exposed via RouteContext:
| State | Used By |
|-------|---------|
| `this.sessions` | All session routes |
| `this.store` | Create, delete, settings |
| `this.mux` | Create (spawn tmux), delete (kill) |
| `this.broadcast()` | Most routes (SSE events) |
| `this.persistSessionState()` | Name, color, auto-ops, cpu-limit |
| `this.cleanupSession()` | Delete |
| `this.setupSessionListeners()` | Create, interactive, shell |
| `this.getSessionStateWithRespawn()` | Get session details |
| `this.batchTerminalData()` | (Indirectly via session listeners) |
| `this.runSummaryTrackers` | Run summary GET |
| `this.getGlobalNiceConfig()` | Create session (nice/ionice config) |
| `this.getModelConfig()` | Create session (default model) |
| `this.getClaudeModeConfig()` | Create session (claude mode, allowed tools) |
| `imageWatcher` | Image watcher toggle (import directly, not via ctx) |
### Session Creation Helper
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' \
-d '{"mode":"shell"}' | jq .id
# List sessions:
curl -s http://localhost:3099/api/sessions | jq length
# Delete session (use ID from create):
curl -s -X DELETE http://localhost:3099/api/sessions/<id> | jq .success
kill %1
```
---
## Task 4: Extract Respawn Routes
**File to create**: `src/web/routes/respawn-routes.ts`
**File to edit**: `src/web/server.ts`
**Time**: ~1.5 hours
### Routes to Extract
| Method | Path | Current Line | Purpose |
|--------|------|-------------|---------|
| GET | `/api/sessions/:id/respawn` | 2142 | Get respawn status |
| GET | `/api/sessions/:id/respawn/config` | 2157 | Get respawn config |
| POST | `/api/sessions/:id/respawn/start` | 2175 | Start respawn |
| POST | `/api/sessions/:id/respawn/stop` | 2221 | Stop respawn |
| PUT | `/api/sessions/:id/respawn/config` | 2256 | Update respawn config |
| POST | `/api/sessions/:id/interactive-respawn` | 2313 | Start interactive respawn |
| POST | `/api/sessions/:id/respawn/enable` | 2388 | Enable/disable respawn |
### Key Dependencies
These routes heavily use:
- `ctx.respawnControllers` — get/create/delete controllers
- `ctx.respawnTimers` — timed respawn duration management
- `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 |
| DELETE | `/api/subagents/:agentId` | 4316 | Kill subagent |
| POST | `/api/subagents/cleanup` | 4331 | Cleanup completed |
| DELETE | `/api/subagents` | 4337 | Kill all subagents |
| GET | `/api/subagent-window-states` | 4162 | Get window positions |
| PUT | `/api/subagent-window-states` | 4174 | Save window positions |
| GET | `/api/subagent-parents` | 4197 | Get parent map |
| PUT | `/api/subagent-parents` | 4209 | Save parent map |
These routes primarily use the `subagentWatcher` singleton (imported directly, not via ctx) and `ctx.store` for window state persistence.
### Mux Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/mux-sessions` | 4230 | List tmux sessions |
| DELETE | `/api/mux-sessions/:sessionId` | 4239 | Kill tmux session |
| POST | `/api/mux-sessions/reconcile` | 4246 | Reconcile sessions |
| POST | `/api/mux-sessions/stats/start` | 4252 | Start stats polling |
| POST | `/api/mux-sessions/stats/stop` | 4258 | Stop stats polling |
| GET | `/api/system/stats` | 4264 | System CPU/memory |
These routes only need `ctx.mux` and `getSystemStats()` (move the helper to the route module or route-context).
### Team Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/teams` | 4345 | List teams |
| GET | `/api/teams/:name/tasks` | 4350 | Get team tasks |
These routes only need `ctx.teamWatcher`.
### Verification
```bash
tsc --noEmit
npm run lint
# Verify subagent listing works:
curl -s http://localhost:3099/api/subagents | jq length
```
---
## Task 6: Extract Plan, Case, and Ralph Routes
**File to create**: `src/web/routes/plan-routes.ts`, `src/web/routes/case-routes.ts`, `src/web/routes/ralph-routes.ts`
**File to edit**: `src/web/server.ts`
**Time**: ~3 hours
### Plan Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| POST | `/api/generate-plan` | 3380 | Generate plan |
| POST | `/api/generate-plan-detailed` | 3574 | Generate detailed plan |
| POST | `/api/cancel-plan-generation` | 3682 | Cancel plan generation |
| PATCH | `/api/sessions/:id/plan/task/:taskId` | 3877 | Update plan task |
| 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 |
| GET | `/api/cases/:caseName/ralph-wizard/files` | 3716 | Wizard file listing |
| GET | `/api/cases/:caseName/ralph-wizard/file/:filePath` | 3777 | Wizard file content |
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 |
| POST | `/api/sessions/:id/ralph-circuit-breaker/reset` | 1733 | Reset circuit breaker |
| 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.
### Verification
```bash
tsc --noEmit
npm run lint
# Verify cases endpoint:
curl -s http://localhost:3099/api/cases | jq length
```
---
## Task 7: Extract System, Settings, Push, File, and Scheduled Routes
**Files to create**: `src/web/routes/system-routes.ts`, `src/web/routes/file-routes.ts`, `src/web/routes/push-routes.ts`, `src/web/routes/scheduled-routes.ts`
**File to edit**: `src/web/server.ts`
**Time**: ~3 hours
### System Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/status` | 842 | Full app status (cached) |
| GET | `/api/tunnel/status` | 844 | Tunnel status |
| GET | `/api/tunnel/qr` | 846 | Tunnel QR code |
| GET | `/api/opencode/status` | 862 | OpenCode CLI check |
| POST | `/api/cleanup-state` | 871 | Clean stale sessions from state |
| GET | `/api/session-lifecycle` | 877 | Lifecycle audit log |
| GET | `/api/stats` | 895 | App statistics |
| GET | `/api/token-stats` | 913 | Token usage stats |
| GET | `/api/config` | 931 | Get app config |
| PUT | `/api/config` | 935 | Update app config |
| GET | `/api/debug/memory` | 947 | Debug memory usage |
| POST | `/api/logout` | 833 | Clear auth cookie |
| 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 |
| GET | `/api/sessions/:id/file-content` | 1388 | File content preview |
| GET | `/api/sessions/:id/file-raw` | 1498 | Raw file download |
| GET | `/api/sessions/:id/tail-file` | 1575 | Live file tail (SSE) |
| DELETE | `/api/sessions/:id/tail-file/:streamId` | 1640 | Stop file tail |
| 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.
**TOCTOU security**: The file-raw route has a critical `realpathSync()` double-check for symlink TOCTOU protection. Preserve this exactly when moving.
### Push Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/push/vapid-key` | 4410 | Get VAPID public key |
| POST | `/api/push/subscribe` | 4414 | Register push subscription |
| PUT | `/api/push/subscribe/:id` | 4431 | Update subscription prefs |
| DELETE | `/api/push/subscribe/:id` | 4444 | Unsubscribe |
Push routes only need `ctx.pushStore`. Very self-contained.
### Scheduled Routes
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| POST | `/api/run` | 2561 | Quick run (create + run prompt) |
| GET | `/api/scheduled` | 2620 | List scheduled runs |
| POST | `/api/scheduled` | 2624 | Create scheduled run |
| DELETE | `/api/scheduled/:id` | 2650 | Cancel scheduled run |
| GET | `/api/scheduled/:id` | 2662 | Get scheduled run |
| POST | `/api/quick-start` | 2965 | Quick start (case + session + hooks) |
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`.
### SSE Route
| Method | Path | Line | Purpose |
|--------|------|------|---------|
| GET | `/api/events` | 803 | SSE connection endpoint |
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.
### Verification
```bash
tsc --noEmit
npm run lint
npm run format:check
# Comprehensive verification:
npx tsx src/index.ts web --port 3099 &
sleep 3
curl -s http://localhost:3099/api/status | jq .status
curl -s http://localhost:3099/api/settings | jq 'keys'
curl -s http://localhost:3099/api/push/vapid-key | jq .publicKey
kill %1
```
---
## Task 8: Final Cleanup — Reduce server.ts
**File to edit**: `src/web/server.ts`
**Time**: ~2 hours
### What Remains in server.ts
After Tasks 2-7, server.ts should contain only:
1. **Class fields** (~60 LOC) — Maps, timers, config
2. **Constructor** (~50 LOC) — Fastify init, mux creation, watcher setup
3. **`setupRoutes()`** (~50 LOC) — Plugin registration + route module imports:
```typescript
private async setupRoutes(): Promise<void> {
// Plugins
await this.app.register(fastifyCompress, { threshold: 1024 });
await this.app.register(fastifyCookie);
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => done(null));
// Auth
const { authSessions, authFailures } = registerAuthMiddleware(this.app, this.https);
this.authSessions = authSessions;
this.authFailures = authFailures;
registerSecurityHeaders(this.app, this.https);
// Error handler
this.app.setErrorHandler((error, _req, reply) => { /* ... */ });
// Static files
await this.app.register(fastifyStatic, { /* ... */ });
// Route modules
const ctx = this.createRouteContext();
registerSessionRoutes(this.app, ctx);
registerRespawnRoutes(this.app, ctx);
registerSubagentRoutes(this.app, ctx);
registerMuxRoutes(this.app, ctx);
registerTeamRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerFileRoutes(this.app, ctx);
registerPushRoutes(this.app, ctx);
registerScheduledRoutes(this.app, ctx);
// SSE endpoint (kept here — tightly coupled to broadcast infra)
this.app.get('/api/events', (req, reply) => { /* ... */ });
// Service worker route (kept here — 20 LOC)
this.app.get('/sw.js', async (_req, reply) => { /* ... */ });
}
```
4. **`createRouteContext()`** (~40 LOC) — Build context object
5. **`start()`** (~90 LOC) — Server startup, session restoration
6. **`stop()`** (~170 LOC) — Graceful shutdown
7. **Session listener setup/teardown** (~200 LOC) — `setupSessionListeners`, `removeSessionListeners`
8. **Respawn lifecycle** (~200 LOC) — `setupRespawnListeners`, `setupTimedRespawn`, `restoreRespawnController`, `saveRespawnConfig`
9. **Watcher setup** (~100 LOC) — `setupSubagentWatcherListeners`, `setupImageWatcherListeners`, `setupTeamWatcherListeners`
10. **SSE infrastructure** (~100 LOC) — `broadcast`, `sendSSE`, `sendSSEPreformatted`
11. **Terminal batching** (~80 LOC) — `batchTerminalData`, `flushSessionTerminalBatch`
12. **Task/state update batching** (~60 LOC) — `batchTaskUpdate`, `broadcastSessionStateDebounced`
13. **State persistence** (~60 LOC) — `persistSessionState`, `_persistSessionStateNow`
14. **Session cleanup** (~120 LOC) — `cleanupSession`, `_doCleanupSession`
15. **Transcript watchers** (~50 LOC) — `startTranscriptWatcher`, `stopTranscriptWatcher`
16. **State caching** (~60 LOC) — `getLightState`, `getLightSessionsState`
**Estimated total**: ~1,300-1,500 LOC
### Cleanup Steps
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)
grep -c "app\.\(get\|post\|put\|patch\|delete\)(" src/web/routes/*.ts src/web/server.ts
# 5. Integration test — start server, verify key endpoints
npx tsx src/index.ts web --port 3099 &
sleep 3
# System
curl -s http://localhost:3099/api/status | jq .status
curl -s http://localhost:3099/api/config | jq .version
# Sessions
SESS=$(curl -s -X POST http://localhost:3099/api/sessions -H 'Content-Type: application/json' \
-d '{"mode":"shell"}' | jq -r .id)
curl -s http://localhost:3099/api/sessions | jq length
curl -s http://localhost:3099/api/sessions/$SESS | jq .id
# Subagents
curl -s http://localhost:3099/api/subagents | jq length
# Cases
curl -s http://localhost:3099/api/cases | jq length
# Settings
curl -s http://localhost:3099/api/settings | jq 'keys'
# Push
curl -s http://localhost:3099/api/push/vapid-key | jq .publicKey
# Mux
curl -s http://localhost:3099/api/mux-sessions | jq length
# Cleanup
curl -s -X DELETE http://localhost:3099/api/sessions/$SESS | jq .success
kill %1
# 6. Run any existing tests
npx vitest run test/respawn-controller.test.ts
npx vitest run test/session-manager.test.ts
```
---
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| `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` |
---
## What NOT to Touch
- **`app.js`** (frontend monolith) — that's Phase 5
- **`schemas.ts`** — validation schemas stay as-is
- **Terminal batching internals** — `batchTerminalData`, `flushSessionTerminalBatch` stay in server.ts
- **SSE infrastructure** — `broadcast`, `sendSSE`, `sendSSEPreformatted` stay in server.ts
- **Session listener wiring** — complex event handler setup stays in server.ts
- **Respawn lifecycle methods** — `setupRespawnListeners`, `restoreRespawnController` stay in server.ts
- **Session cleanup** — `cleanupSession`, `_doCleanupSession` stay in server.ts (complex cross-cutting logic)
- **State caching** — `getLightState`, `getLightSessionsState` stay in server.ts
The goal is to extract **route definitions** (HTTP handler logic), not **orchestration** (lifecycle, events, batching).
---
## Summary of Changes
| Task | Files Created | Key Change |
|------|-------------|------------|
| 1. RouteContext | `src/web/route-context.ts` | Shared interface + `findSessionOrFail` helper |
| 2. Auth middleware | `src/web/middleware/auth.ts` | Auth hooks + security headers extracted |
| 3. Session routes | `src/web/routes/session-routes.ts` | ~23 routes, ~800 LOC extracted |
| 4. Respawn routes | `src/web/routes/respawn-routes.ts` | ~7 routes, ~400 LOC extracted |
| 5. Subagent/mux/team | `src/web/routes/subagent-routes.ts`, `mux-routes.ts`, `team-routes.ts` | ~18 routes, ~380 LOC extracted |
| 6. Plan/case/ralph | `src/web/routes/plan-routes.ts`, `case-routes.ts`, `ralph-routes.ts` | ~24 routes, ~1350 LOC extracted |
| 7. System/file/push/sched | `src/web/routes/system-routes.ts`, `file-routes.ts`, `push-routes.ts`, `scheduled-routes.ts` | ~28 routes, ~1500 LOC extracted |
| 8. Final cleanup | `src/web/server.ts` reduced | ~5,200 LOC removed from server.ts |
**Total files created**: 14 (1 interface + 1 middleware + 12 route modules)
**Total files modified**: 1 (server.ts)
**Net LOC change**: ~0 (moved, not deleted — but server.ts drops from ~6,710 to ~1,500)
**Route count preserved**: ~110 routes (verified by grep after extraction)