Restored from origin/main (b4663fb): - .github/ workflows and issue templates - .gitignore (proper exclusions) - .opencode/agent/web_developer.md - AGENTS.md, BUILD.md, PROGRESS.md - dev-docs/ (9 architecture/implementation docs) - docs/screenshots/ (4 UI screenshots) - images/ (CodeNomad icons) - package-lock.json (dependency lockfile) - tasks/ (25+ project task files) Also restored original source files that were modified: - packages/ui/src/App.tsx - packages/ui/src/lib/logger.ts - packages/ui/src/stores/instances.ts - packages/server/src/server/routes/workspaces.ts - packages/server/src/workspaces/manager.ts - packages/server/src/workspaces/runtime.ts - packages/server/package.json Kept new additions: - Install-*.bat/.sh (enhanced installers) - Launch-*.bat/.sh (new launchers) - README.md (SEO optimized with GLM 4.7)
9.0 KiB
9.0 KiB
Task 003: OpenCode Server Process Management
Goal
Implement the ability to spawn, manage, and kill OpenCode server processes from the Electron main process.
Prerequisites
- Task 001 completed (project setup)
- Task 002 completed (folder selection working)
- OpenCode CLI installed and in PATH
- Understanding of Node.js child_process API
Acceptance Criteria
- Can spawn
opencode servefor a folder - Parses stdout to extract port number
- Returns port and PID to renderer
- Handles spawn errors gracefully
- Can kill process on command
- Captures and forwards stdout/stderr
- Timeout protection (10 seconds)
- Process cleanup on app quit
Steps
1. Create Process Manager Module
electron/main/process-manager.ts:
Exports:
interface ProcessInfo {
pid: number
port: number
}
interface ProcessManager {
spawn(folder: string): Promise<ProcessInfo>
kill(pid: number): Promise<void>
getStatus(pid: number): "running" | "stopped" | "unknown"
getAllProcesses(): Map<number, ProcessMeta>
}
interface ProcessMeta {
pid: number
port: number
folder: string
startTime: number
childProcess: ChildProcess
}
2. Implement Spawn Logic
spawn(folder: string):
Pre-flight checks:
- Verify
opencodebinary exists in PATH- Use
which opencodeorwhere opencode - If not found, reject with helpful error
- Use
- Verify folder exists and is directory
- Use
fs.stat()to check - If invalid, reject with error
- Use
- Verify folder is readable
- Check permissions
- If denied, reject with error
Process spawning:
- Use
child_process.spawn() - Command:
opencode - Args:
['serve', '--port', '0']- Port 0 = random available port
- Options:
cwd: The selected folderstdio:['ignore', 'pipe', 'pipe']- stdin: ignore
- stdout: pipe (we'll read it)
- stderr: pipe (for errors)
env: Inherit process.envshell: false (security)
Port extraction:
- Listen to stdout data events
- Buffer output line by line
- Regex match:
/Server listening on port (\d+)/or similar - Extract port number when found
- Store process metadata
- Resolve promise with { pid, port }
Timeout handling:
- Set 10 second timeout
- If port not found within timeout:
- Kill the process
- Reject promise with timeout error
- Clear timeout once port found
Error handling:
- Listen to process 'error' event
- Common: ENOENT (binary not found)
- Reject promise immediately
- Listen to process 'exit' event
- If exits before port found:
- Read stderr buffer
- Reject with exit code and stderr
- If exits before port found:
3. Implement Kill Logic
kill(pid: number):
Find process:
- Look up pid in internal Map
- If not found, reject with "Process not found"
Graceful shutdown:
- Send SIGTERM signal first
- Wait 2 seconds
- If still running, send SIGKILL
- Remove from internal Map
- Resolve when process exits
Cleanup:
- Close stdio streams
- Remove all event listeners
- Free resources
4. Implement Status Check
getStatus(pid: number):
Check if running:
- On Unix: Use
process.kill(pid, 0)- Returns true if running
- Throws if not running
- On Windows: Use tasklist or similar
- Return 'running', 'stopped', or 'unknown'
5. Add Process Tracking
Internal state:
const processes = new Map<number, ProcessMeta>()
Track all spawned processes:
- Add on successful spawn
- Remove on kill or exit
- Use for cleanup on app quit
6. Implement Auto-cleanup
On app quit:
- Listen to app 'before-quit' event
- Kill all tracked processes
- Wait for all to exit (with timeout)
- Prevent quit until cleanup done
On process crash:
- Listen to process 'exit' event
- If unexpected exit:
- Log error
- Notify renderer via IPC
- Remove from tracking
7. Add Logging
Log output forwarding:
- Listen to stdout/stderr
- Parse into lines
- Send to renderer via IPC events
- Event: 'instance:log'
- Payload: { pid, level: 'info' | 'error', message }
Log important events:
- Process spawned
- Port discovered
- Process exited
- Errors occurred
8. Add IPC Handlers
electron/main/ipc.ts (new file):
Register handlers:
ipcMain.handle("process:spawn", async (event, folder: string) => {
return await processManager.spawn(folder)
})
ipcMain.handle("process:kill", async (event, pid: number) => {
return await processManager.kill(pid)
})
ipcMain.handle("process:status", async (event, pid: number) => {
return processManager.getStatus(pid)
})
Send events:
// When process exits unexpectedly
webContents.send("process:exited", { pid, code, signal })
// When log output received
webContents.send("process:log", { pid, level, message })
9. Update Preload Script
electron/preload/index.ts additions:
Expose methods:
electronAPI: {
spawnServer: (folder: string) => Promise<{ pid: number, port: number }>
killServer: (pid: number) => Promise<void>
getServerStatus: (pid: number) => Promise<string>
onServerExited: (callback: (data: any) => void) => void
onServerLog: (callback: (data: any) => void) => void
}
Type definitions:
interface ProcessInfo {
pid: number
port: number
}
interface ElectronAPI {
// ... previous methods
spawnServer: (folder: string) => Promise<ProcessInfo>
killServer: (pid: number) => Promise<void>
getServerStatus: (pid: number) => Promise<"running" | "stopped" | "unknown">
onServerExited: (callback: (data: { pid: number; code: number }) => void) => void
onServerLog: (callback: (data: { pid: number; level: string; message: string }) => void) => void
}
10. Create Instance Store
src/stores/instances.ts:
State:
interface Instance {
id: string // UUID
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
}
interface InstanceStore {
instances: Map<string, Instance>
activeInstanceId: string | null
}
Actions:
async function createInstance(folder: string) {
const id = generateId()
// Add with 'starting' status
instances.set(id, {
id,
folder,
port: 0,
pid: 0,
status: "starting",
})
try {
// Spawn server
const { pid, port } = await window.electronAPI.spawnServer(folder)
// Update with port and pid
instances.set(id, {
...instances.get(id)!,
port,
pid,
status: "ready",
})
return id
} catch (error) {
// Update with error
instances.set(id, {
...instances.get(id)!,
status: "error",
error: error.message,
})
throw error
}
}
async function removeInstance(id: string) {
const instance = instances.get(id)
if (!instance) return
// Kill server
if (instance.pid) {
await window.electronAPI.killServer(instance.pid)
}
// Remove from store
instances.delete(id)
// If was active, clear active
if (activeInstanceId === id) {
activeInstanceId = null
}
}
11. Wire Up Folder Selection
src/App.tsx updates:
After folder selected:
async function handleSelectFolder() {
const folder = await window.electronAPI.selectFolder()
if (!folder) return
try {
const instanceId = await createInstance(folder)
setActiveInstance(instanceId)
// Hide empty state, show instance UI
setHasInstances(true)
} catch (error) {
console.error("Failed to create instance:", error)
// TODO: Show error toast
}
}
Listen for process exit:
onMount(() => {
window.electronAPI.onServerExited(({ pid }) => {
// Find instance by PID
const instance = Array.from(instances.values()).find((i) => i.pid === pid)
if (instance) {
// Update status
instances.set(instance.id, {
...instance,
status: "stopped",
})
// TODO: Show notification (Task 010)
}
})
})
Testing Checklist
Manual Tests:
- Select folder → Server spawns
- Console shows "Spawned PID: XXX, Port: YYYY"
- Check
ps aux | grep opencode→ Process running - Quit app → Process killed
- Select invalid folder → Error shown
- Select without opencode installed → Helpful error
- Spawn multiple instances → All tracked
- Kill one instance → Others continue running
Error Cases:
- opencode not in PATH
- Permission denied on folder
- Port already in use (should not happen with port 0)
- Server crashes immediately
- Timeout (server takes >10s to start)
Edge Cases:
- Very long folder path
- Folder with spaces in name
- Folder on network drive (slow to spawn)
- Multiple instances same folder (different ports)
Dependencies
- Blocks: Task 004 (needs running server to connect SDK)
- Blocked by: Task 001, Task 002
Estimated Time
4-5 hours
Notes
- Security: Never use shell execution with user input
- Cross-platform: Test on macOS, Windows, Linux
- Error messages must be actionable
- Log everything for debugging
- Consider rate limiting (max 10 instances?)
- Memory: Track process memory usage (future enhancement)