feat(backend): add unified backend server package#49
Conversation
Implement a persistent per-project backend service that consolidates Express HTTP API, WebSocket, MCP endpoint, terminal PTY management, and file watching into a single process. New files: - packages/cli/src/backend/types.ts — Type definitions - packages/cli/src/backend/terminal.ts — PTY manager + session store - packages/cli/src/backend/server.ts — Express app with all routes - packages/cli/src/backend/lifecycle.ts — Start/stop/health/discovery - packages/cli/src/backend/index.ts — Public exports - packages/cli/src/backend-server.ts — Entry point Modified: - packages/cli/src/cli.ts — backend-start/stop/status commands - packages/cli/tsdown.config.ts — backend-server build entry - packages/cli/package.json — express, ws, chokidar, detect-port deps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a unified, persistent per-project backend process for MAXSIM, consolidating multiple services (HTTP API, WebSockets, MCP HTTP endpoint, terminal PTY, file watching) behind new CLI lifecycle commands.
Changes:
- Add a new backend server entrypoint/bundle and supporting backend modules (
server,lifecycle,terminal,types). - Add CLI commands to start/stop/query backend status with dynamic imports (
backend-start,backend-stop,backend-status). - Update dependencies and regenerate
dist/artifacts + docs/assets.
Reviewed changes
Copilot reviewed 10 out of 72 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/cli/tsdown.config.ts | Adds backend-server build entry and bundling/externalization settings. |
| packages/cli/src/cli.ts | Adds backend-start/stop/status CLI commands. |
| packages/cli/src/backend/types.ts | Defines backend config/status/message types. |
| packages/cli/src/backend/terminal.ts | Implements PTY management + scrollback store. |
| packages/cli/src/backend/lifecycle.ts | Implements backend start/stop/status + lockfile behavior. |
| packages/cli/src/backend/server.ts | Implements unified Express + WS + MCP + watcher + terminal backend server. |
| packages/cli/src/backend/index.ts | Re-exports backend types and lifecycle/server functions. |
| packages/cli/src/backend-server.ts | Backend server process entrypoint (env-driven). |
| packages/cli/package.json | Adds express/ws/chokidar/detect-port deps and type packages. |
| package-lock.json | Locks updated for new deps/version bump. |
| packages/cli/README.md | Updates docs for Claude-only + skills/MCP sections. |
| packages/cli/dist/skills-DYictYGI.cjs | Regenerated build artifact. |
| packages/cli/dist/mcp-server.cjs | Regenerated build artifact. |
| packages/cli/dist/lifecycle-B6gdn2NV.cjs.map | Regenerated build artifact. |
| packages/cli/dist/lifecycle-B6gdn2NV.cjs | Regenerated build artifact. |
| packages/cli/dist/install.cjs | Regenerated build artifact. |
| packages/cli/dist/core/skills.js.map | Regenerated build artifact. |
| packages/cli/dist/core/skills.js | Regenerated build artifact. |
| packages/cli/dist/core/skills.d.ts.map | Regenerated build artifact. |
| packages/cli/dist/core/skills.d.ts | Regenerated build artifact. |
| packages/cli/dist/core-Cqn3M3eD.cjs | Regenerated build artifact. |
| packages/cli/dist/cli.js | Regenerated build artifact. |
| packages/cli/dist/cli.cjs | Regenerated build artifact. |
| packages/cli/dist/backend/types.js.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/types.js | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/types.d.ts.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/types.d.ts | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/terminal.js.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/terminal.js | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/terminal.d.ts.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/terminal.d.ts | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/server.js | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/server.d.ts.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/server.d.ts | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/lifecycle.js.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/lifecycle.js | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/lifecycle.d.ts.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/lifecycle.d.ts | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/index.js.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/index.js | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/index.d.ts.map | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend/index.d.ts | Regenerated build artifact for new backend module. |
| packages/cli/dist/backend-server.js.map | Regenerated build artifact for new entrypoint. |
| packages/cli/dist/backend-server.js | Regenerated build artifact for new entrypoint. |
| packages/cli/dist/backend-server.d.ts.map | Regenerated build artifact for new entrypoint. |
| packages/cli/dist/backend-server.d.ts | Regenerated build artifact for new entrypoint. |
| packages/cli/dist/backend-server.d.cts | Regenerated build artifact for new entrypoint. |
| packages/cli/dist/assets/templates/skills/verification-before-completion/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/templates/skills/using-maxsim/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/templates/skills/systematic-debugging/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/templates/skills/simplify/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/templates/skills/roadmap-writing/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/templates/skills/memory-management/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/templates/skills/code-review/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/templates/skills/brainstorming/SKILL.md | Regenerated/updated skill template content. |
| packages/cli/dist/assets/dashboard/server.js | Regenerated dashboard asset bundle. |
| packages/cli/dist/assets/CHANGELOG.md | Regenerated packaged changelog in assets. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 1. Check if already running | ||
| const existing = await getBackendStatus(projectCwd); | ||
| if (existing && existing.status === 'ok') { | ||
| return readLockFile(projectCwd)!; | ||
| } |
There was a problem hiding this comment.
startBackend() returns readLockFile(projectCwd)! when health says the backend is running. This non-null assertion can still crash on a race (lockfile deleted between the health check and re-read). Prefer returning the lock data you already have (read once) or handle the null case by re-creating the lockfile from existing/health response.
| export async function startBackend( | ||
| projectCwd: string, | ||
| opts?: { port?: number; background?: boolean }, | ||
| ): Promise<BackendLockFile> { | ||
| // 1. Check if already running | ||
| const existing = await getBackendStatus(projectCwd); | ||
| if (existing && existing.status === 'ok') { | ||
| return readLockFile(projectCwd)!; | ||
| } | ||
|
|
||
| // 2. Choose port | ||
| const port = opts?.port ?? projectPort(projectCwd); | ||
| const background = opts?.background ?? true; | ||
|
|
||
| if (background) { | ||
| // Spawn detached child process running backend-server.cjs | ||
| const serverScript = path.join(__dirname, 'backend-server.cjs'); | ||
| const child = spawn(process.execPath, [serverScript], { | ||
| cwd: projectCwd, | ||
| detached: true, | ||
| stdio: 'ignore', | ||
| env: { | ||
| ...process.env, | ||
| MAXSIM_PORT: String(port), | ||
| MAXSIM_PROJECT_CWD: projectCwd, | ||
| }, | ||
| ...(process.platform === 'win32' ? { shell: true } : {}), | ||
| }); | ||
| child.unref(); | ||
|
|
||
| const lockData: BackendLockFile = { | ||
| pid: child.pid ?? 0, | ||
| port, | ||
| startedAt: Date.now(), | ||
| cwd: projectCwd, | ||
| }; | ||
|
|
||
| writeLockFile(projectCwd, lockData); | ||
| return lockData; |
There was a problem hiding this comment.
The new backend lifecycle logic (lockfile handling + start/stop/status) introduces several edge cases (stale lockfile cleanup, port selection, PID handling). There don’t appear to be unit tests covering these behaviors; adding focused tests for lockfile creation/removal and status detection would help prevent regressions.
| if (background) { | ||
| // Spawn detached child process running backend-server.cjs | ||
| const serverScript = path.join(__dirname, 'backend-server.cjs'); | ||
| const child = spawn(process.execPath, [serverScript], { | ||
| cwd: projectCwd, |
There was a problem hiding this comment.
In background mode the backend script path is resolved as path.join(__dirname, 'backend-server.cjs'). When this file is compiled to dist/backend/lifecycle.js, __dirname will be dist/backend, so this points to a non-existent dist/backend/backend-server.cjs and the spawn will fail. Resolve the script relative to the dist root instead (e.g., path.join(__dirname, '..', 'backend-server.cjs') in CJS builds, or use import.meta.url/fileURLToPath for ESM-safe resolution).
| const lockData: BackendLockFile = { | ||
| pid: child.pid ?? 0, | ||
| port, | ||
| startedAt: Date.now(), | ||
| cwd: projectCwd, | ||
| }; |
There was a problem hiding this comment.
lockData.pid is set to child.pid ?? 0. If spawn fails before PID assignment (or on platforms where PID can be undefined), writing 0 is dangerous: stopBackend() later calls process.kill(lock.pid, 'SIGTERM'), and process.kill(0, ...) signals the entire process group on POSIX. Treat missing PID as a start failure (don’t write a lockfile), or store pid: null and guard against killing when PID is not a positive integer.
| // 2. Choose port | ||
| const port = opts?.port ?? projectPort(projectCwd); | ||
| const background = opts?.background ?? true; | ||
|
|
||
| if (background) { | ||
| // Spawn detached child process running backend-server.cjs | ||
| const serverScript = path.join(__dirname, 'backend-server.cjs'); | ||
| const child = spawn(process.execPath, [serverScript], { | ||
| cwd: projectCwd, | ||
| detached: true, | ||
| stdio: 'ignore', | ||
| env: { | ||
| ...process.env, | ||
| MAXSIM_PORT: String(port), | ||
| MAXSIM_PROJECT_CWD: projectCwd, | ||
| }, | ||
| ...(process.platform === 'win32' ? { shell: true } : {}), | ||
| }); | ||
| child.unref(); | ||
|
|
||
| const lockData: BackendLockFile = { | ||
| pid: child.pid ?? 0, | ||
| port, | ||
| startedAt: Date.now(), | ||
| cwd: projectCwd, | ||
| }; | ||
|
|
||
| writeLockFile(projectCwd, lockData); | ||
| return lockData; |
There was a problem hiding this comment.
The lock file records port before the child process starts, but the backend server uses detect-port and may bind to a different port if the requested one is taken. If that happens, the lock file becomes stale immediately (health checks and shutdown will hit the wrong port, and getBackendStatus() will delete the lockfile while the server is still running). Fix by selecting an available port in the parent (using detect-port here too) before spawning, or by having the child report its final bound port back so the lockfile can be written/updated with the actual port.
| function isWithinPlanning(cwd: string, targetPath: string): boolean { | ||
| const planningDir = path.resolve(cwd, '.planning'); | ||
| const resolved = path.resolve(cwd, targetPath); | ||
| return resolved.startsWith(planningDir); | ||
| } |
There was a problem hiding this comment.
isWithinPlanning() uses resolved.startsWith(planningDir), which is vulnerable to prefix matches like <cwd>/.planning2/... (outside the .planning/ directory) being treated as allowed. Use a boundary-aware check like resolved === planningDir || resolved.startsWith(planningDir + path.sep) (or path.relative(planningDir, resolved) and ensure it doesn’t start with ..).
| 'backend-start': async (args, cwd, raw) => { | ||
| const { startBackend } = await import('./backend/lifecycle.js'); | ||
| const portFlag = args.find(a => a.startsWith('--port='))?.split('=')[1]; | ||
| const background = !args.includes('--foreground'); | ||
| const result = await startBackend(cwd, { | ||
| port: portFlag ? parseInt(portFlag, 10) : undefined, | ||
| background, | ||
| }); |
There was a problem hiding this comment.
--port= is parsed with parseInt(portFlag, 10) and passed through even if it’s invalid (NaN) or out of range. Since NaN is not nullish, startBackend() will treat it as an explicit port and propagate it into MAXSIM_PORT, which can break detect-port / server startup. Validate the parsed number (finite integer, 1-65535) and return a CLI error when invalid.
| const port = parseInt(process.env.MAXSIM_PORT || '3142', 10); | ||
| const cwd = process.env.MAXSIM_PROJECT_CWD || process.cwd(); | ||
|
|
There was a problem hiding this comment.
MAXSIM_PORT is parsed with parseInt(...) but not validated. If the env var is missing/invalid (or passed through as "NaN"), the backend will start with port: NaN, which can cause runtime failures when selecting/listening on a port. Add a guard (e.g., fall back to a default when Number.isNaN(port)), and consider rejecting non-numeric input with a clear stderr error + nonzero exit.
|
🎉 This PR is included in version 4.1.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
packages/cli/src/backend/(types, terminal, server, lifecycle, index) plus entry pointbackend-server.tsbackend-start,backend-stop,backend-statuswith dynamic imports for zero overhead on other commandsTest plan
/api/healthendpoint responds correctly🤖 Generated with Claude Code