Files
NomadArch/tasks/done/003-process-manager.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
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)
2025-12-23 13:03:48 +04:00

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 serve for 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 opencode binary exists in PATH
    • Use which opencode or where opencode
    • If not found, reject with helpful error
  • Verify folder exists and is directory
    • Use fs.stat() to check
    • If invalid, reject with error
  • 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 folder
    • stdio: ['ignore', 'pipe', 'pipe']
      • stdin: ignore
      • stdout: pipe (we'll read it)
      • stderr: pipe (for errors)
    • env: Inherit process.env
    • shell: 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

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:

  1. Select folder → Server spawns
  2. Console shows "Spawned PID: XXX, Port: YYYY"
  3. Check ps aux | grep opencode → Process running
  4. Quit app → Process killed
  5. Select invalid folder → Error shown
  6. Select without opencode installed → Helpful error
  7. Spawn multiple instances → All tracked
  8. 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)