Restructure project creation, listing, GitHub clone progress, and TaskMaster details behind a dedicated TypeScript module under server/modules/projects/, and align the client wizard with a single path-based flow. Server / routing - Remove server/routes/projects.js and mount server/modules/projects/ projects.routes.ts at /api/projects (still behind authenticateToken). - Drop duplicate handlers from server/index.js for GET /api/projects and GET /api/projects/:projectId/taskmaster; those live on the new router. - Import WORKSPACES_ROOT and validateWorkspacePath from shared utils in index.js instead of the deleted projects route module. Projects router (projects.routes.ts) - GET /: list projects with sessions (existing snapshot behavior). - POST /create-project: validate body, reject legacy workspaceType and mixed clone fields, delegate to createProject service, return distinct success copy when an archived path is reactivated. - GET /clone-progress: Server-Sent Events for clone progress/complete/error; requires authenticated user id for token resolution; wires startCloneProject. - GET /:projectId/taskmaster: delegates to getProjectTaskMaster. Services (new) - project-management.service.ts: path validation, workspace directory creation, persistence via projectsDb.createProjectPath, mapping to API project shape; surfaces AppError for validation, conflict, and not-found cases; optional dependency injection for tests. - project-clone.service.ts: validates workspace, resolves GitHub auth (stored token or inline token), runs git clone with progress callbacks, registers project via createProject on success; sanitizes errors and supports cancellation; injectable dependencies for tests. - projects-has-taskmaster.service.ts: moves TaskMaster detection and normalization out of server/projects.js; resolve-by-id and public getProjectTaskMaster with structured AppError responses. Persistence and shared types - projectsDb.createProjectPath now returns CreateProjectPathResult (created | reactivated_archived | active_conflict) using INSERT … ON CONFLICT with selective update when the row is archived; normalizes display name from path or custom name; repository row typing moves to shared ProjectRepositoryRow. - getProjectPaths() returns only non-archived rows (isArchived = 0). - shared/types.ts: ProjectRepositoryRow, CreateProjectPathResult/outcome, WorkspacePathValidationResult. - shared/utils.ts: WORKSPACES_ROOT, forbidden path lists, validateWorkspacePath, asyncHandler for Express async routes. Legacy cleanup - server/projects.js: remove detectTaskMasterFolder, normalizeTaskMasterInfo, and getProjectTaskMasterById (logic lives in the new service). - server/routes/agent.js: register external API project paths with projectsDb.createProjectPath instead of addProjectManually try/catch; treat active_conflict as an existing registration and continue. Tests - Add Node test suites for project-management, project-clone, and projects-has-taskmaster services; update projects.service test import for renamed projects-with-sessions-fetch.service.ts. Rename - projects.service.ts → projects-with-sessions-fetch.service.ts; re-export from modules/projects/index.ts. Client (project creation wizard) - Remove StepTypeSelection and workspaceType from form state and types; wizard is two steps (configure path/GitHub auth, then review). - createWorkspaceRequest → createProjectRequest; clone vs create-only inferred from githubUrl (pathUtils / isCloneWorkflow). - Adjust step indices, WizardProgress, StepConfiguration/Review, WorkspacePathField, and src/utils/api.js as needed for the new API. Docs - Minor websocket README touch-up. Net: ~1.6k insertions / ~0.9k deletions across 29 files; behavior is centralized in typed services with explicit HTTP errors and test seams.
WebSocket Module
This module owns the server-side WebSocket gateway used by:
- Chat streaming (
/ws) - Interactive terminal sessions (
/shell) - Plugin WebSocket passthrough (
/plugin-ws/:pluginName)
It is intentionally structured as small services plus a barrel export in index.ts.
Public API
server/modules/websocket/index.ts exports:
createWebSocketServer(server, dependencies)
Creates and wires the sharedwsserver.connectedClientsandWS_OPEN_STATE
Shared chat client registry and open-state constant used by other modules.
Why Dependency Injection Is Used
The module receives runtime-specific functions from server/index.js instead of importing legacy runtime files directly.
Benefits:
- Keeps module boundaries clean (
server/modules/*architecture rule). - Makes each service easier to test in isolation.
- Keeps WebSocket transport concerns separate from provider runtime concerns.
Service Map
| File | Responsibility |
|---|---|
services/websocket-server.service.ts |
Creates WebSocketServer, binds verifyClient, routes connection by pathname |
services/websocket-auth.service.ts |
Authenticates upgrade requests and attaches request.user |
services/chat-websocket.service.ts |
Handles /ws chat protocol and provider command/session control messages |
services/shell-websocket.service.ts |
Handles /shell PTY lifecycle, reconnect buffering, auth URL detection |
services/plugin-websocket-proxy.service.ts |
Bridges client socket to plugin socket |
services/websocket-writer.service.ts |
Adapts raw WebSocket to writer interface (send, setSessionId, getSessionId) |
services/websocket-state.service.ts |
Holds shared chat client set and open-state constant |
High-Level Architecture
flowchart LR
A[HTTP Server] --> B[createWebSocketServer]
B --> C[verifyWebSocketClient]
B --> D{Pathname}
D -->|/ws| E[handleChatConnection]
D -->|/shell| F[handleShellConnection]
D -->|/plugin-ws/:name| G[handlePluginWsProxy]
D -->|other| H[close()]
E --> I[connectedClients Set]
E --> J[WebSocketWriter]
F --> K[ptySessionsMap]
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
I --> M[projects.service broadcastProgress]
I --> N[sessions-watcher.service projects_updated]
Connection Handshake + Routing
sequenceDiagram
participant Client
participant WSS as WebSocketServer
participant Auth as verifyWebSocketClient
participant Router as connection router
participant Chat as /ws handler
participant Shell as /shell handler
participant Proxy as /plugin-ws handler
Client->>WSS: Upgrade Request
WSS->>Auth: verifyClient(info)
alt Platform mode
Auth->>Auth: authenticateWebSocket(null)
Auth->>Auth: attach request.user
else OSS mode
Auth->>Auth: read token from ?token or Authorization
Auth->>Auth: authenticateWebSocket(token)
Auth->>Auth: attach request.user
end
alt Auth failed
Auth-->>WSS: false (reject handshake)
else Auth ok
Auth-->>WSS: true
WSS->>Router: on("connection", ws, request)
alt pathname == /ws
Router->>Chat: handleChatConnection(ws, request, deps.chat)
else pathname == /shell
Router->>Shell: handleShellConnection(ws, deps.shell)
else pathname startsWith /plugin-ws/
Router->>Proxy: handlePluginWsProxy(ws, pathname, getPluginPort)
else unknown
Router->>Router: ws.close()
end
end
/ws Chat Flow
When a chat socket connects:
- Add socket to
connectedClients. - Build
WebSocketWriter(capturesuserIdfrom authenticated request). - Parse each incoming message with
parseIncomingJsonObject. - Dispatch by
data.type. - On close, remove socket from
connectedClients.
Chat Message Dispatch
flowchart TD
A[Incoming WS message] --> B[parseIncomingJsonObject]
B -->|invalid| C[send {type:error}]
B -->|ok| D{data.type}
D -->|claude-command| E[queryClaudeSDK]
D -->|cursor-command| F[spawnCursor]
D -->|codex-command| G[queryCodex]
D -->|gemini-command| H[spawnGemini]
D -->|cursor-resume| I[spawnCursor resume]
D -->|abort-session| J[abort by provider]
D -->|claude-permission-response| K[resolveToolApproval]
D -->|cursor-abort| L[abortCursorSession]
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
D -->|get-active-sessions| O[getActive*Sessions]
Chat Notes
abort-sessionreturns a normalizedcompletemessage withaborted: true.check-session-statusreturns{ type: "session-status", isProcessing }.- Claude status checks can reconnect output stream to the new socket via
reconnectSessionWriter.
/shell Terminal Flow
The shell handler manages persistent PTY sessions keyed by:
<projectPath>_<sessionIdOrDefault>[_cmd_<hash>]
This enables reconnect behavior and isolates command-specific plain-shell sessions.
Shell Lifecycle
stateDiagram-v2
[*] --> WaitingInit
WaitingInit --> ValidateInit: message.type == init
ValidateInit --> ReconnectExisting: session key exists and not login reset
ValidateInit --> SpawnNewPTY: valid path + valid sessionId
ValidateInit --> EmitError: invalid payload/path/sessionId
ReconnectExisting --> Running: attach ws, replay buffer
SpawnNewPTY --> Running: pty.spawn + wire onData/onExit
Running --> Running: input -> pty.write
Running --> Running: resize -> pty.resize
Running --> Running: onData -> buffer + output + auth_url detection
Running --> Exited: onExit
Running --> Detached: ws close
Detached --> Running: reconnect before timeout
Detached --> Killed: timeout reached -> pty.kill
Exited --> [*]
Killed --> [*]
EmitError --> WaitingInit
Shell Behaviors in Detail
init: ReadsprojectPath,sessionId,provider,hasSession,initialCommand,isPlainShell.- Login reset: For login-like commands, existing keyed PTY session is killed and recreated.
- Validation:
Path must exist and be a directory;
sessionIdmust match safe pattern. - Command build: Provider-specific command construction with resume semantics.
- PTY output buffering: Stores up to 5000 chunks for replay on reconnect.
- URL detection:
Strips ANSI, accumulates text buffer, extracts URLs, emits
auth_urlonce per normalized URL, supportsautoOpen. - Close behavior: Socket disconnect does not instantly kill PTY; session is kept alive and terminated on timeout.
/plugin-ws/:pluginName Proxy Flow
sequenceDiagram
participant Client
participant Proxy as handlePluginWsProxy
participant PM as getPluginPort
participant Upstream as Plugin WS
Client->>Proxy: Connect /plugin-ws/:name
Proxy->>Proxy: Validate pluginName regex
alt Invalid name
Proxy-->>Client: close(4400, "Invalid plugin name")
else Valid
Proxy->>PM: getPluginPort(name)
alt Plugin not running
Proxy-->>Client: close(4404, "Plugin not running")
else Port found
Proxy->>Upstream: new WebSocket(ws://127.0.0.1:port/ws)
Client-->>Upstream: relay messages bidirectionally
Upstream-->>Client: relay messages bidirectionally
Upstream-->>Client: close propagation
Client-->>Upstream: close propagation
Upstream-->>Client: close(4502, "Upstream error") on upstream error
end
end
Shared Client Registry and Broadcasts
Only chat sockets (/ws) are tracked in connectedClients.
That shared set is consumed by:
modules/projects/services/projects-with-sessions-fetch.service.tsBroadcastsloading_progresswhile project snapshots are being built.modules/providers/services/sessions-watcher.service.tsBroadcastsprojects_updatedwhen provider session artifacts change.
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
Writer Adapter (WebSocketWriter)
WebSocketWriter normalizes chat transport behavior to match existing writer-style interfaces used elsewhere.
Methods:
send(data)
JSON-serializes and sends only if socket is open.setSessionId(sessionId)/getSessionId()
Supports provider session bookkeeping and resume flows.updateWebSocket(newRawWs)
Allows active session stream redirection on reconnect.
Error Handling and Close Codes
Current explicit close codes in this module:
4400: Invalid plugin name4404: Plugin not running4502: Upstream plugin WebSocket error
Other errors:
- Chat handler catches and emits
{ type: "error", error }. - Shell handler catches and writes terminal-visible error output.
- Unknown websocket paths are closed immediately.
Extending This Module
To add a new websocket route:
- Add a new handler service under
services/. - Extend
WebSocketServerDependenciesinwebsocket-server.service.tsif needed. - Add a new pathname branch in the router.
- Wire dependency injection from
server/index.js. - Keep
index.tsas barrel-only export surface.