From 575f987b8f0611127ec73f0b4514e38968946abf Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 24 Dec 2025 00:59:41 +0000 Subject: [PATCH] Add background process manager and UI panel --- .../plugin/background-process.ts | 157 +++++++ .../plugin/lib/legacy/backgroundShell.ts | 341 ++++++++++++++ packages/server/src/api-types.ts | 27 ++ .../src/background-processes/manager.ts | 437 ++++++++++++++++++ packages/server/src/server/http-server.ts | 9 + .../src/server/routes/background-processes.ts | 83 ++++ .../background-process-output-dialog.tsx | 101 ++++ .../components/instance/instance-shell2.tsx | 120 ++++- packages/ui/src/lib/api-client.ts | 85 +++- packages/ui/src/lib/sse-manager.ts | 25 + .../ui/src/stores/background-processes.ts | 66 +++ 11 files changed, 1446 insertions(+), 5 deletions(-) create mode 100644 packages/opencode-config/plugin/background-process.ts create mode 100644 packages/opencode-config/plugin/lib/legacy/backgroundShell.ts create mode 100644 packages/server/src/background-processes/manager.ts create mode 100644 packages/server/src/server/routes/background-processes.ts create mode 100644 packages/ui/src/components/background-process-output-dialog.tsx create mode 100644 packages/ui/src/stores/background-processes.ts diff --git a/packages/opencode-config/plugin/background-process.ts b/packages/opencode-config/plugin/background-process.ts new file mode 100644 index 00000000..e50245cb --- /dev/null +++ b/packages/opencode-config/plugin/background-process.ts @@ -0,0 +1,157 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { getCodeNomadConfig } from "./lib/client" + +type BackgroundProcess = { + id: string + title: string + command: string + status: "running" | "stopped" | "error" + startedAt: string + stoppedAt?: string + exitCode?: number + outputSizeBytes?: number +} + +export async function BackgroundProcessPlugin() { + const config = getCodeNomadConfig() + + const request = async (path: string, init?: RequestInit): Promise => { + const base = config.baseUrl.replace(/\/+$/, "") + const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}` + const headers = normalizeHeaders(init?.headers) + if (init?.body !== undefined) { + headers["Content-Type"] = "application/json" + } + + const response = await fetch(url, { + ...init, + headers, + }) + + if (!response.ok) { + const message = await response.text() + throw new Error(message || `Request failed with ${response.status}`) + } + + if (response.status === 204) { + return undefined as T + } + + return (await response.json()) as T + } + + return { + tool: { + run_background_process: tool({ + description: "Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.", + args: { + title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"), + command: tool.schema.string().describe("Shell command to run in the workspace"), + }, + async execute(args) { + const process = await request("", { + method: "POST", + body: JSON.stringify({ title: args.title, command: args.command }), + }) + + return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}` + }, + }), + list_background_processes: tool({ + description: "List background processes running for this workspace.", + args: {}, + async execute() { + const response = await request<{ processes: BackgroundProcess[] }>("") + if (response.processes.length === 0) { + return "No background processes running." + } + + return response.processes + .map((process) => { + const status = process.status === "running" ? "running" : process.status + const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : "" + const size = typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : "" + return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}` + }) + .join("\n") + }, + }), + read_background_process_output: tool({ + description: "Read output from a background process. Use full, grep, head, or tail.", + args: { + id: tool.schema.string().describe("Background process ID"), + method: tool.schema + .enum(["full", "grep", "head", "tail"]) + .default("full") + .describe("Method to read output"), + pattern: tool.schema.string().optional().describe("Pattern for grep method"), + lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"), + }, + async execute(args) { + if (args.method === "grep" && !args.pattern) { + return "Pattern is required for grep method." + } + + const params = new URLSearchParams({ method: args.method }) + if (args.pattern) { + params.set("pattern", args.pattern) + } + if (args.lines) { + params.set("lines", String(args.lines)) + } + + const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>( + `/${args.id}/output?${params.toString()}`, + ) + + const header = response.truncated + ? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):` + : `Output (${Math.round(response.sizeBytes / 1024)}KB):` + + return `${header}\n\n${response.content}` + }, + }), + stop_background_process: tool({ + description: "Stop a background process (SIGTERM) but keep its output and entry.", + args: { + id: tool.schema.string().describe("Background process ID"), + }, + async execute(args) { + const process = await request(`/${args.id}/stop`, { method: "POST" }) + return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}` + }, + }), + terminate_background_process: tool({ + description: "Terminate a background process and delete its output + entry.", + args: { + id: tool.schema.string().describe("Background process ID"), + }, + async execute(args) { + await request(`/${args.id}/terminate`, { method: "POST" }) + return `Terminated background process ${args.id} and removed its output.` + }, + }), + }, + } +} + +function normalizeHeaders(headers: HeadersInit | undefined): Record { + const output: Record = {} + if (!headers) return output + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + output[key] = value + }) + return output + } + + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + output[key] = value + } + return output + } + + return { ...headers } +} diff --git a/packages/opencode-config/plugin/lib/legacy/backgroundShell.ts b/packages/opencode-config/plugin/lib/legacy/backgroundShell.ts new file mode 100644 index 00000000..815f95d1 --- /dev/null +++ b/packages/opencode-config/plugin/lib/legacy/backgroundShell.ts @@ -0,0 +1,341 @@ +import { tool } from "@opencode-ai/plugin" +import { spawn, ChildProcess, exec } from "child_process" +import { promisify } from "util" +import { promises as fs } from "fs" +import { existsSync, mkdirSync } from "fs" +import { join } from "path" +import { randomBytes } from "crypto" + +const execAsync = promisify(exec) + +// Global registry to track running processes +const runningProcesses = new Map() +const SHELLS_DIR = ".opencode/backgroundShells" +const INDEX_FILE = join(SHELLS_DIR, "index.json") + +interface ShellMetadata { + id: string + command: string + startTime: number + status: "running" | "finished" + exitCode?: number + workingDir: string +} + +// Cleanup function called on startup +async function cleanupStaleShells() { + try { + if (existsSync(SHELLS_DIR)) { + const dirs = await fs.readdir(SHELLS_DIR) + for (const dir of dirs) { + if (dir !== "index.json") { + await fs.rm(join(SHELLS_DIR, dir), { recursive: true, force: true }) + } + } + if (existsSync(INDEX_FILE)) { + await fs.unlink(INDEX_FILE) + } + } + } catch (error) { + console.error("Failed to cleanup stale shells:", error) + } +} + +// Initialize cleanup on first import +cleanupStaleShells() + +async function ensureShellsDir() { + if (!existsSync(SHELLS_DIR)) { + mkdirSync(SHELLS_DIR, { recursive: true }) + } +} + +async function loadIndex(): Promise { + try { + if (existsSync(INDEX_FILE)) { + const content = await fs.readFile(INDEX_FILE, "utf-8") + return JSON.parse(content) + } + } catch (error) { + // Index file corrupted or missing, return empty array + } + return [] +} + +async function saveIndex(shells: ShellMetadata[]) { + await ensureShellsDir() + await fs.writeFile(INDEX_FILE, JSON.stringify(shells, null, 2)) +} + +function generateId(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) + const random = randomBytes(3).toString("hex") + return `task_${timestamp}_${random}` +} + +function formatRuntime(startTime: number): string { + const elapsed = Date.now() - startTime + const seconds = Math.floor(elapsed / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s` + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s` + } else { + return `${seconds}s` + } +} + +async function getShellsList(): Promise { + const shells = await loadIndex() + if (shells.length === 0) { + return "Currently running Background Shells:\nNo background shells running." + } + + let output = "Currently running Background Shells:\n" + for (const shell of shells) { + const runtime = shell.status === "running" ? formatRuntime(shell.startTime) : "finished" + const outputPath = join(SHELLS_DIR, shell.id, "output.txt") + let outputSize = 0 + try { + const stats = await fs.stat(outputPath) + outputSize = stats.size + } catch { + // File doesn't exist yet + } + + output += `- ShellId: ${shell.id}\n` + output += ` Command: ${shell.command}\n` + output += ` Status: ${shell.status === "running" ? "Running" : "Finished"}\n` + output += ` ${shell.status === "running" ? "RunningFor" : "CompletedAfter"}: ${runtime}\n` + output += ` Current output size: ${outputSize} bytes\n` + if (shell.status === "finished" && shell.exitCode !== undefined) { + output += ` Exit code: ${shell.exitCode}\n` + } + output += "\n" + } + return output.trim() +} + +export const start = tool({ + description: `Start a background shell command. Only use it to run supporting processes like dev servers etc. ${await getShellsList()}`, + args: { + command: tool.schema.string().describe("Bash command to execute in background"), + }, + async execute(args) { + await ensureShellsDir() + + const id = generateId() + const shellDir = join(SHELLS_DIR, id) + const outputFile = join(shellDir, "output.txt") + + // Create directory for this shell + mkdirSync(shellDir, { recursive: true }) + + // Create metadata + const metadata: ShellMetadata = { + id, + command: args.command, + startTime: Date.now(), + status: "running", + workingDir: process.cwd() + } + + // Start the process + const childProcess = spawn("bash", ["-c", args.command], { + detached: false, // Keep it in opencode process space + stdio: ["ignore", "pipe", "pipe"], + cwd: process.cwd() + }) + + // Store process reference + runningProcesses.set(id, childProcess) + + // Create output stream + const outputStream = await fs.open(outputFile, "w") + + // Pipe stdout and stderr to output file + childProcess.stdout?.on("data", async (data) => { + await outputStream.write(data) + }) + + childProcess.stderr?.on("data", async (data) => { + await outputStream.write(data) + }) + + // Handle process completion + childProcess.on("close", async (code) => { + await outputStream.close() + runningProcesses.delete(id) + + // Update metadata + metadata.status = "finished" + metadata.exitCode = code || 0 + + // Update index + const shells = await loadIndex() + const index = shells.findIndex(s => s.id === id) + if (index >= 0) { + shells[index] = metadata + } + await saveIndex(shells) + }) + + // Add to index + const shells = await loadIndex() + shells.push(metadata) + await saveIndex(shells) + + return `Started background shell with ID: ${id}\nCommand: ${args.command}\n\n${await getShellsList()}` + }, +}) + +export const list = tool({ + description: "List all background shell processes with their status", + args: {}, + async execute(args) { + return await getShellsList() + }, +}) + +export const read = tool({ + description: `Read output from a background shell process. ${await getShellsList()}`, + args: { + id: tool.schema.string().describe("Background shell ID"), + method: tool.schema.enum(["full", "grep", "head", "tail"]).default("full").describe("Method to read output"), + pattern: tool.schema.string().optional().describe("Pattern for grep method"), + lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"), + }, + async execute(args) { + const shells = await loadIndex() + const shell = shells.find(s => s.id === args.id) + + if (!shell) { + return `Background shell with ID ${args.id} not found.\n\n${await getShellsList()}` + } + + const outputFile = join(SHELLS_DIR, args.id, "output.txt") + + if (!existsSync(outputFile)) { + return `Output file for ${args.id} not found or not created yet.` + } + + try { + // Check file size first + const stats = await fs.stat(outputFile) + const fileSizeBytes = stats.size + const fileSizeKB = (fileSizeBytes / 1024).toFixed(2) + + let content: string + let command: string + + switch (args.method) { + case "full": + // For full read, check size and warn if too large + if (fileSizeBytes > 102400) { // 100KB + return `Output file is too large (${fileSizeKB}KB). Please use grep, head, or tail methods to filter the content.` + } + content = await fs.readFile(outputFile, "utf-8") + break + + case "grep": + if (!args.pattern) { + return "Pattern is required for grep method" + } + // Use actual grep command - escape the pattern for shell safety + const escapedPattern = args.pattern.replace(/"/g, '\\"') + command = `grep "${escapedPattern}" "${outputFile}"` + + try { + const { stdout } = await execAsync(command) + content = stdout + } catch (error: any) { + // grep returns exit code 1 when no matches found + if (error.code === 1) { + content = "" + } else { + throw error + } + } + break + + case "head": + const headLines = args.lines || 10 + // Use actual head command + command = `head -n ${headLines} "${outputFile}"` + + const headResult = await execAsync(command) + content = headResult.stdout + break + + case "tail": + const tailLines = args.lines || 10 + // Use actual tail command + command = `tail -n ${tailLines} "${outputFile}"` + + const tailResult = await execAsync(command) + content = tailResult.stdout + break + + default: + if (fileSizeBytes > 102400) { // 100KB + return `Output file is too large (${fileSizeKB}KB). Please use grep, head, or tail methods to filter the content.` + } + content = await fs.readFile(outputFile, "utf-8") + } + + // Check the final content size after grep/head/tail processing + if (content.length > 102400) { // 100KB in characters (roughly) + return `Filtered output is still too large (${(content.length / 1024).toFixed(2)}KB). Try using more specific grep patterns or fewer lines for head/tail.` + } + + const methodDesc = args.method === "grep" ? `grep pattern: "${args.pattern}"` : + args.method === "head" ? `head ${args.lines || 10} lines` : + args.method === "tail" ? `tail ${args.lines || 10} lines` : + "full output" + + return `Output for ${args.id} (${methodDesc}) - File size: ${fileSizeKB}KB:\n\n${content}` + + } catch (error) { + return `Error reading output for ${args.id}: ${error}` + } + }, +}) + +export const kill = tool({ + description: `Kill a background shell process and clean up its files. ${await getShellsList()}`, + args: { + id: tool.schema.string().describe("Background shell ID to kill"), + }, + async execute(args) { + const shells = await loadIndex() + const shell = shells.find(s => s.id === args.id) + + if (!shell) { + return `Background shell with ID ${args.id} not found.\n\n${await getShellsList()}` + } + + // Kill the process if it's still running + const process = runningProcesses.get(args.id) + if (process) { + process.kill("SIGTERM") + runningProcesses.delete(args.id) + } + + // Remove from index + const updatedShells = shells.filter(s => s.id !== args.id) + await saveIndex(updatedShells) + + // Remove directory + const shellDir = join(SHELLS_DIR, args.id) + try { + await fs.rm(shellDir, { recursive: true, force: true }) + } catch (error) { + // Directory might not exist, continue + } + + return `Killed and cleaned up background shell ${args.id}\n\n${await getShellsList()}` + }, +}) \ No newline at end of file diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 7ad858cc..b889cac3 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -219,6 +219,33 @@ export interface ServerMeta { latestRelease?: LatestReleaseInfo } +export type BackgroundProcessStatus = "running" | "stopped" | "error" + +export interface BackgroundProcess { + id: string + workspaceId: string + title: string + command: string + cwd: string + status: BackgroundProcessStatus + pid?: number + startedAt: string + stoppedAt?: string + exitCode?: number + outputSizeBytes?: number +} + +export interface BackgroundProcessListResponse { + processes: BackgroundProcess[] +} + +export interface BackgroundProcessOutputResponse { + id: string + content: string + truncated: boolean + sizeBytes: number +} + export type { Preferences, ModelPreference, diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts new file mode 100644 index 00000000..878a0409 --- /dev/null +++ b/packages/server/src/background-processes/manager.ts @@ -0,0 +1,437 @@ +import { spawn, type ChildProcess } from "child_process" +import { createWriteStream, existsSync, promises as fs } from "fs" +import path from "path" +import { randomBytes } from "crypto" +import type { EventBus } from "../events/bus" +import type { WorkspaceManager } from "../workspaces/manager" +import type { Logger } from "../logger" +import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types" + +const ROOT_DIR = ".codenomad/background_processes" +const INDEX_FILE = "index.json" +const OUTPUT_FILE = "output.txt" +const STOP_TIMEOUT_MS = 2000 +const MAX_OUTPUT_BYTES = 20 * 1024 +const OUTPUT_PUBLISH_INTERVAL_MS = 1000 + +interface ManagerDeps { + workspaceManager: WorkspaceManager + eventBus: EventBus + logger: Logger +} + +interface RunningProcess { + child: ChildProcess + outputPath: string + exitPromise: Promise + workspaceId: string +} + +export class BackgroundProcessManager { + private readonly running = new Map() + + constructor(private readonly deps: ManagerDeps) { + this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId)) + this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id)) + } + + async list(workspaceId: string): Promise { + const records = await this.readIndex(workspaceId) + const enriched = await Promise.all( + records.map(async (record) => ({ + ...record, + outputSizeBytes: await this.getOutputSize(workspaceId, record.id), + })), + ) + return enriched + } + + async start(workspaceId: string, title: string, command: string): Promise { + const workspace = this.deps.workspaceManager.get(workspaceId) + if (!workspace) { + throw new Error("Workspace not found") + } + + const id = this.generateId() + const processDir = await this.ensureProcessDir(workspaceId, id) + const outputPath = path.join(processDir, OUTPUT_FILE) + + const outputStream = createWriteStream(outputPath, { flags: "a" }) + + const child = spawn("bash", ["-c", command], { + cwd: workspace.path, + stdio: ["ignore", "pipe", "pipe"], + }) + + const record: BackgroundProcess = { + id, + workspaceId, + title, + command, + cwd: workspace.path, + status: "running", + pid: child.pid, + startedAt: new Date().toISOString(), + outputSizeBytes: 0, + } + + const exitPromise = new Promise((resolve) => { + child.on("close", async (code) => { + await new Promise((resolve) => outputStream.end(resolve)) + this.running.delete(id) + + record.status = this.statusFromExit(code) + record.exitCode = code === null ? undefined : code + record.stoppedAt = new Date().toISOString() + + await this.upsertIndex(workspaceId, record) + record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) + this.publishUpdate(workspaceId, record) + resolve() + }) + }) + + this.running.set(id, { child, outputPath, exitPromise, workspaceId }) + + let lastPublishAt = 0 + const maybePublishSize = () => { + const now = Date.now() + if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) { + return + } + lastPublishAt = now + this.publishUpdate(workspaceId, record) + } + + child.stdout?.on("data", (data) => { + outputStream.write(data) + record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length + maybePublishSize() + }) + child.stderr?.on("data", (data) => { + outputStream.write(data) + record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length + maybePublishSize() + }) + + await this.upsertIndex(workspaceId, record) + record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) + this.publishUpdate(workspaceId, record) + return record + } + + async stop(workspaceId: string, processId: string): Promise { + const record = await this.findProcess(workspaceId, processId) + if (!record) { + return null + } + + const running = this.running.get(processId) + if (running?.child && !running.child.killed) { + running.child.kill("SIGTERM") + await this.waitForExit(running) + } + + if (record.status === "running") { + record.status = "stopped" + record.stoppedAt = new Date().toISOString() + await this.upsertIndex(workspaceId, record) + record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) + this.publishUpdate(workspaceId, record) + } + + return record + } + + async terminate(workspaceId: string, processId: string): Promise { + const record = await this.findProcess(workspaceId, processId) + if (!record) return + + const running = this.running.get(processId) + if (running?.child && !running.child.killed) { + running.child.kill("SIGTERM") + await this.waitForExit(running) + } + + await this.removeFromIndex(workspaceId, processId) + await this.removeProcessDir(workspaceId, processId) + + this.deps.eventBus.publish({ + type: "instance.event", + instanceId: workspaceId, + event: { type: "background.process.removed", properties: { processId } }, + }) + } + + async readOutput( + workspaceId: string, + processId: string, + options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number }, + ) { + const outputPath = this.getOutputPath(workspaceId, processId) + if (!existsSync(outputPath)) { + return { id: processId, content: "", truncated: false, sizeBytes: 0 } + } + + const stats = await fs.stat(outputPath) + const sizeBytes = stats.size + const method = options.method ?? "full" + const lineCount = options.lines ?? 10 + + const raw = await this.readOutputBytes(outputPath, sizeBytes) + let content = raw + + switch (method) { + case "head": + content = this.headLines(raw, lineCount) + break + case "tail": + content = this.tailLines(raw, lineCount) + break + case "grep": + if (!options.pattern) { + throw new Error("Pattern is required for grep output") + } + content = this.grepLines(raw, options.pattern) + break + default: + content = raw + } + + return { + id: processId, + content, + truncated: sizeBytes > MAX_OUTPUT_BYTES, + sizeBytes, + } + } + + async streamOutput(workspaceId: string, processId: string, reply: any) { + const outputPath = this.getOutputPath(workspaceId, processId) + if (!existsSync(outputPath)) { + reply.code(404).send({ error: "Output not found" }) + return + } + + reply.raw.setHeader("Content-Type", "text/event-stream") + reply.raw.setHeader("Cache-Control", "no-cache") + reply.raw.setHeader("Connection", "keep-alive") + reply.raw.flushHeaders?.() + reply.hijack() + + const file = await fs.open(outputPath, "r") + let position = (await file.stat()).size + + const tick = async () => { + const stats = await file.stat() + if (stats.size <= position) return + + const length = stats.size - position + const buffer = Buffer.alloc(length) + await file.read(buffer, 0, length, position) + position = stats.size + + const content = buffer.toString("utf-8") + reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`) + } + + const interval = setInterval(() => { + tick().catch((error) => { + this.deps.logger.warn({ err: error }, "Failed to stream background process output") + }) + }, 1000) + + const close = () => { + clearInterval(interval) + file.close().catch(() => undefined) + reply.raw.end?.() + } + + reply.raw.on("close", close) + reply.raw.on("error", close) + } + + private async cleanupWorkspace(workspaceId: string) { + for (const [, running] of this.running.entries()) { + if (running.workspaceId !== workspaceId) continue + running.child.kill("SIGTERM") + await this.waitForExit(running) + } + await this.removeWorkspaceDir(workspaceId) + } + + private async waitForExit(running: RunningProcess) { + let resolved = false + const timeout = setTimeout(() => { + if (!resolved) { + running.child.kill("SIGKILL") + } + }, STOP_TIMEOUT_MS) + + await running.exitPromise.finally(() => { + resolved = true + clearTimeout(timeout) + }) + } + + private statusFromExit(code: number | null): BackgroundProcessStatus { + if (code === null) return "stopped" + if (code === 0) return "stopped" + return "error" + } + + private async readOutputBytes(outputPath: string, sizeBytes: number): Promise { + if (sizeBytes <= MAX_OUTPUT_BYTES) { + return await fs.readFile(outputPath, "utf-8") + } + + const start = Math.max(0, sizeBytes - MAX_OUTPUT_BYTES) + const file = await fs.open(outputPath, "r") + const buffer = Buffer.alloc(sizeBytes - start) + await file.read(buffer, 0, buffer.length, start) + await file.close() + return buffer.toString("utf-8") + } + + private headLines(input: string, lines: number): string { + const parts = input.split(/\r?\n/) + return parts.slice(0, Math.max(0, lines)).join("\n") + } + + private tailLines(input: string, lines: number): string { + const parts = input.split(/\r?\n/) + return parts.slice(Math.max(0, parts.length - lines)).join("\n") + } + + private grepLines(input: string, pattern: string): string { + let matcher: RegExp + try { + matcher = new RegExp(pattern) + } catch { + throw new Error("Invalid grep pattern") + } + return input + .split(/\r?\n/) + .filter((line) => matcher.test(line)) + .join("\n") + } + + private async ensureProcessDir(workspaceId: string, processId: string) { + const root = await this.ensureWorkspaceDir(workspaceId) + const processDir = path.join(root, processId) + await fs.mkdir(processDir, { recursive: true }) + return processDir + } + + private async ensureWorkspaceDir(workspaceId: string) { + const workspace = this.deps.workspaceManager.get(workspaceId) + if (!workspace) { + throw new Error("Workspace not found") + } + const root = path.join(workspace.path, ROOT_DIR, workspaceId) + await fs.mkdir(root, { recursive: true }) + return root + } + + private getOutputPath(workspaceId: string, processId: string) { + const workspace = this.deps.workspaceManager.get(workspaceId) + if (!workspace) { + throw new Error("Workspace not found") + } + return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE) + } + + private async findProcess(workspaceId: string, processId: string): Promise { + const records = await this.readIndex(workspaceId) + return records.find((entry) => entry.id === processId) ?? null + } + + private async readIndex(workspaceId: string): Promise { + const indexPath = await this.getIndexPath(workspaceId) + if (!existsSync(indexPath)) return [] + + try { + const raw = await fs.readFile(indexPath, "utf-8") + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : [] + } catch { + return [] + } + } + + private async upsertIndex(workspaceId: string, record: BackgroundProcess) { + const records = await this.readIndex(workspaceId) + const index = records.findIndex((entry) => entry.id === record.id) + if (index >= 0) { + records[index] = record + } else { + records.push(record) + } + await this.writeIndex(workspaceId, records) + } + + private async removeFromIndex(workspaceId: string, processId: string) { + const records = await this.readIndex(workspaceId) + const next = records.filter((entry) => entry.id !== processId) + await this.writeIndex(workspaceId, next) + } + + private async writeIndex(workspaceId: string, records: BackgroundProcess[]) { + const indexPath = await this.getIndexPath(workspaceId) + await fs.mkdir(path.dirname(indexPath), { recursive: true }) + await fs.writeFile(indexPath, JSON.stringify(records, null, 2)) + } + + private async getIndexPath(workspaceId: string) { + const workspace = this.deps.workspaceManager.get(workspaceId) + if (!workspace) { + throw new Error("Workspace not found") + } + return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE) + } + + private async removeProcessDir(workspaceId: string, processId: string) { + const workspace = this.deps.workspaceManager.get(workspaceId) + if (!workspace) { + return + } + const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId) + await fs.rm(processDir, { recursive: true, force: true }) + } + + private async removeWorkspaceDir(workspaceId: string) { + const workspace = this.deps.workspaceManager.get(workspaceId) + if (!workspace) { + return + } + const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId) + await fs.rm(workspaceDir, { recursive: true, force: true }) + } + + private async getOutputSize(workspaceId: string, processId: string): Promise { + const outputPath = this.getOutputPath(workspaceId, processId) + if (!existsSync(outputPath)) { + return 0 + } + try { + const stats = await fs.stat(outputPath) + return stats.size + } catch { + return 0 + } + } + + private publishUpdate(workspaceId: string, record: BackgroundProcess) { + this.deps.eventBus.publish({ + type: "instance.event", + instanceId: workspaceId, + event: { type: "background.process.updated", properties: { process: record } }, + }) + } + + private generateId(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) + const random = randomBytes(3).toString("hex") + return `proc_${timestamp}_${random}` + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 0af7b408..26542790 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -19,8 +19,10 @@ import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" +import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" +import { BackgroundProcessManager } from "../background-processes/manager" interface HttpServerDeps { host: string @@ -101,6 +103,12 @@ export function createHttpServer(deps: HttpServerDeps) { }, }) + const backgroundProcessManager = new BackgroundProcessManager({ + workspaceManager: deps.workspaceManager, + eventBus: deps.eventBus, + logger: deps.logger.child({ component: "background-processes" }), + }) + registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) @@ -112,6 +120,7 @@ export function createHttpServer(deps: HttpServerDeps) { workspaceManager: deps.workspaceManager, }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger }) + registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/background-processes.ts b/packages/server/src/server/routes/background-processes.ts new file mode 100644 index 00000000..5603326b --- /dev/null +++ b/packages/server/src/server/routes/background-processes.ts @@ -0,0 +1,83 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import type { BackgroundProcessManager } from "../../background-processes/manager" + +interface RouteDeps { + backgroundProcessManager: BackgroundProcessManager +} + +const StartSchema = z.object({ + title: z.string().trim().min(1), + command: z.string().trim().min(1), +}) + +const OutputQuerySchema = z.object({ + method: z.enum(["full", "tail", "head", "grep"]).optional(), + mode: z.enum(["full", "tail", "head", "grep"]).optional(), + pattern: z.string().optional(), + lines: z.coerce.number().int().positive().max(2000).optional(), +}) + +export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => { + const processes = await deps.backgroundProcessManager.list(request.params.id) + return { processes } + }) + + app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => { + const payload = StartSchema.parse(request.body ?? {}) + const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command) + reply.code(201) + return process + }) + + app.post<{ Params: { id: string; processId: string } }>( + "/workspaces/:id/plugin/background-processes/:processId/stop", + async (request, reply) => { + const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId) + if (!process) { + reply.code(404) + return { error: "Process not found" } + } + return process + }, + ) + + app.post<{ Params: { id: string; processId: string } }>( + "/workspaces/:id/plugin/background-processes/:processId/terminate", + async (request, reply) => { + await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId) + reply.code(204) + return undefined + }, + ) + + app.get<{ Params: { id: string; processId: string } }>( + "/workspaces/:id/plugin/background-processes/:processId/output", + async (request, reply) => { + const query = OutputQuerySchema.parse(request.query ?? {}) + const method = query.method ?? query.mode + if (method === "grep" && !query.pattern) { + reply.code(400) + return { error: "Pattern is required for grep output" } + } + try { + return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, { + method, + pattern: query.pattern, + lines: query.lines, + }) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid output request" } + } + }, + ) + + app.get<{ Params: { id: string; processId: string } }>( + "/workspaces/:id/plugin/background-processes/:processId/stream", + async (request, reply) => { + await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply) + }, + ) +} diff --git a/packages/ui/src/components/background-process-output-dialog.tsx b/packages/ui/src/components/background-process-output-dialog.tsx new file mode 100644 index 00000000..40fdfedd --- /dev/null +++ b/packages/ui/src/components/background-process-output-dialog.tsx @@ -0,0 +1,101 @@ +import { Dialog } from "@kobalte/core/dialog" +import { Show, createEffect, createSignal, onCleanup } from "solid-js" +import type { BackgroundProcess } from "../../../server/src/api-types" +import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client" + +interface BackgroundProcessOutputDialogProps { + open: boolean + instanceId: string + process: BackgroundProcess | null + onClose: () => void +} + +export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) { + const [output, setOutput] = createSignal("") + const [truncated, setTruncated] = createSignal(false) + const [loading, setLoading] = createSignal(false) + + createEffect(() => { + const process = props.process + if (!props.open || !process) { + return + } + + let eventSource: EventSource | null = null + let active = true + + setLoading(true) + serverApi + .fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full" }) + .then((response) => { + if (!active) return + setOutput(response.content) + setTruncated(response.truncated) + }) + .catch(() => { + if (!active) return + setOutput("Failed to load output.") + }) + .finally(() => { + if (!active) return + setLoading(false) + }) + + eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id)) + eventSource.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as { type?: string; content?: string } + if (payload?.type === "chunk" && typeof payload.content === "string") { + setOutput((prev) => `${prev}${payload.content}`) + } + } catch { + // ignore parse errors + } + } + + onCleanup(() => { + active = false + eventSource?.close() + }) + }) + + return ( + !open && props.onClose()} modal> + + +
+ +
+
+ Background Output + + + {props.process?.title} ยท {props.process?.id} + + {props.process?.command} + +
+ + +
+
+ +

Loading output...

+
+ + +

Output truncated for display.

+
+
+                  {output()}
+                
+
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index a5af55e5..454ffdf0 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -12,7 +12,7 @@ import { } from "solid-js" import type { ToolState } from "@opencode-ai/sdk" import { Accordion } from "@kobalte/core" -import { ChevronDown } from "lucide-solid" +import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import AppBar from "@suid/material/AppBar" import Box from "@suid/material/Box" import Divider from "@suid/material/Divider" @@ -28,6 +28,7 @@ import PushPinIcon from "@suid/icons-material/PushPin" import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" import type { Instance } from "../../types/instance" import type { Command } from "../../lib/commands" +import type { BackgroundProcess } from "../../../../server/src/api-types" import { activeParentSessionId, activeSessionId as activeSessionMap, @@ -56,6 +57,9 @@ import SessionView from "../session/session-view" import { formatTokenTotal } from "../../lib/formatters" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" +import { serverApi } from "../../lib/api-client" +import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" +import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { SESSION_SIDEBAR_EVENT, type SessionSidebarRequestAction, @@ -128,7 +132,15 @@ const InstanceShell2: Component = (props) => { const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0) - const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(["plan", "mcp", "lsp", "plugins"]) + const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ + "plan", + "background-processes", + "mcp", + "lsp", + "plugins", + ]) + const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) + const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) @@ -152,6 +164,13 @@ const InstanceShell2: Component = (props) => { persistPinState(side, value) } + createEffect(() => { + const instanceId = props.instance.id + loadBackgroundProcesses(instanceId).catch((error) => { + log.warn("Failed to load background processes", error) + }) + }) + createEffect(() => { switch (layoutMode()) { case "desktop": { @@ -314,6 +333,8 @@ const InstanceShell2: Component = (props) => { return state }) + const backgroundProcessList = createMemo(() => getBackgroundProcesses(props.instance.id)) + const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatusClass = () => { const status = connectionStatus() @@ -326,6 +347,32 @@ const InstanceShell2: Component = (props) => { showCommandPalette(props.instance.id) } + const openBackgroundOutput = (process: BackgroundProcess) => { + setSelectedBackgroundProcess(process) + setShowBackgroundOutput(true) + } + + const closeBackgroundOutput = () => { + setShowBackgroundOutput(false) + setSelectedBackgroundProcess(null) + } + + const stopBackgroundProcess = async (processId: string) => { + try { + await serverApi.stopBackgroundProcess(props.instance.id, processId) + } catch (error) { + log.warn("Failed to stop background process", error) + } + } + + const terminateBackgroundProcess = async (processId: string) => { + try { + await serverApi.terminateBackgroundProcess(props.instance.id, processId) + } catch (error) { + log.warn("Failed to terminate background process", error) + } + } + const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) @@ -853,12 +900,74 @@ const InstanceShell2: Component = (props) => { return } + const renderBackgroundProcesses = () => { + const processes = backgroundProcessList() + if (processes.length === 0) { + return

No background processes.

+ } + + return ( +
+ + {(process) => ( +
+
+ {process.title} +
+ Status: {process.status} + + Output: {Math.round((process.outputSizeBytes ?? 0) / 1024)}KB + +
+
+
+ + + +
+
+ )} +
+
+ ) + } + const sections = [ { id: "plan", label: "Plan", render: renderPlanSectionContent, }, + { + id: "background-processes", + label: "Background Shells", + render: renderBackgroundProcesses, + }, { id: "mcp", label: "MCP Servers", @@ -1313,6 +1422,13 @@ const InstanceShell2: Component = (props) => { commands={instancePaletteCommands()} onExecute={props.onExecuteCommand} /> + + ) } diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 12f79865..4ed51c1e 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -1,5 +1,8 @@ import type { AppConfig, + BackgroundProcess, + BackgroundProcessListResponse, + BackgroundProcessOutputResponse, BinaryCreateRequest, BinaryListResponse, BinaryUpdateRequest, @@ -28,6 +31,12 @@ const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH) export const CODENOMAD_API_BASE = API_BASE +export function buildBackgroundProcessStreamUrl(instanceId: string, processId: string): string { + const encodedInstanceId = encodeURIComponent(instanceId) + const encodedProcessId = encodeURIComponent(processId) + return buildAbsoluteUrl(`/workspaces/${encodedInstanceId}/plugin/background-processes/${encodedProcessId}/stream`) +} + function buildEventsUrl(base: string | undefined, path: string): string { if (path.startsWith("http://") || path.startsWith("https://")) { return path @@ -39,9 +48,41 @@ function buildEventsUrl(base: string | undefined, path: string): string { return path } +function buildAbsoluteUrl(path: string): string { + if (path.startsWith("http://") || path.startsWith("https://")) { + return path + } + if (!API_BASE) { + return path + } + const normalized = path.startsWith("/") ? path : `/${path}` + return `${API_BASE}${normalized}` +} + const httpLogger = getLogger("api") const sseLogger = getLogger("sse") +function normalizeHeaders(headers: HeadersInit | undefined): Record { + const output: Record = {} + if (!headers) return output + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + output[key] = value + }) + return output + } + + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + output[key] = value + } + return output + } + + return { ...headers } +} + function logHttp(message: string, context?: Record) { if (context) { httpLogger.info(message, context) @@ -52,9 +93,9 @@ function logHttp(message: string, context?: Record) { async function request(path: string, init?: RequestInit): Promise { const url = API_BASE ? new URL(path, API_BASE).toString() : path - const headers: HeadersInit = { - "Content-Type": "application/json", - ...(init?.headers ?? {}), + const headers = normalizeHeaders(init?.headers) + if (init?.body !== undefined) { + headers["Content-Type"] = "application/json" } const method = (init?.method ?? "GET").toUpperCase() @@ -186,6 +227,44 @@ export const serverApi = { deleteInstanceData(id: string): Promise { return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" }) }, + listBackgroundProcesses(instanceId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes`, + ) + }, + stopBackgroundProcess(instanceId: string, processId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/stop`, + { method: "POST" }, + ) + }, + terminateBackgroundProcess(instanceId: string, processId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/terminate`, + { method: "POST" }, + ) + }, + fetchBackgroundProcessOutput( + instanceId: string, + processId: string, + options?: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number }, + ): Promise { + const params = new URLSearchParams() + if (options?.method) { + params.set("method", options.method) + } + if (options?.pattern) { + params.set("pattern", options.pattern) + } + if (options?.lines) { + params.set("lines", String(options.lines)) + } + const query = params.toString() + const suffix = query ? `?${query}` : "" + return request( + `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`, + ) + }, connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) { sseLogger.info(`Connecting to ${EVENTS_URL}`) const source = new EventSource(EVENTS_URL) diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index c1a9c1a1..e59987b7 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -16,6 +16,7 @@ import type { } from "@opencode-ai/sdk" import { serverEvents } from "./server-events" import type { + BackgroundProcess, InstanceStreamEvent, InstanceStreamStatus, WorkspaceEventPayload, @@ -37,6 +38,20 @@ interface TuiToastEvent { } } +interface BackgroundProcessUpdatedEvent { + type: "background.process.updated" + properties: { + process: BackgroundProcess + } +} + +interface BackgroundProcessRemovedEvent { + type: "background.process.removed" + properties: { + processId: string + } +} + type SSEEvent = | MessageUpdateEvent | MessageRemovedEvent @@ -50,6 +65,8 @@ type SSEEvent = | EventPermissionReplied | EventLspUpdated | TuiToastEvent + | BackgroundProcessUpdatedEvent + | BackgroundProcessRemovedEvent | { type: string; properties?: Record } type ConnectionStatus = InstanceStreamStatus @@ -126,6 +143,12 @@ class SSEManager { case "lsp.updated": this.onLspUpdated?.(instanceId, event as EventLspUpdated) break + case "background.process.updated": + this.onBackgroundProcessUpdated?.(instanceId, event as BackgroundProcessUpdatedEvent) + break + case "background.process.removed": + this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent) + break default: log.warn("Unknown SSE event type", { type: event.type }) } @@ -151,6 +174,8 @@ class SSEManager { onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void + onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void + onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void onConnectionLost?: (instanceId: string, reason: string) => void | Promise getStatus(instanceId: string): ConnectionStatus | null { diff --git a/packages/ui/src/stores/background-processes.ts b/packages/ui/src/stores/background-processes.ts new file mode 100644 index 00000000..20dd1742 --- /dev/null +++ b/packages/ui/src/stores/background-processes.ts @@ -0,0 +1,66 @@ +import { createSignal } from "solid-js" +import type { BackgroundProcess } from "../../../server/src/api-types" +import { serverApi } from "../lib/api-client" +import { sseManager } from "../lib/sse-manager" + +const [backgroundProcesses, setBackgroundProcesses] = createSignal>(new Map()) + +function setProcesses(instanceId: string, processes: BackgroundProcess[]) { + setBackgroundProcesses((prev) => { + const next = new Map(prev) + next.set(instanceId, processes) + return next + }) +} + +function updateProcess(instanceId: string, process: BackgroundProcess) { + setBackgroundProcesses((prev) => { + const next = new Map(prev) + const current = next.get(instanceId) ?? [] + const index = current.findIndex((entry) => entry.id === process.id) + const updated = index >= 0 ? [...current.slice(0, index), process, ...current.slice(index + 1)] : [...current, process] + next.set(instanceId, updated) + return next + }) +} + +function removeProcess(instanceId: string, processId: string) { + setBackgroundProcesses((prev) => { + const next = new Map(prev) + const current = next.get(instanceId) ?? [] + next.set( + instanceId, + current.filter((entry) => entry.id !== processId), + ) + return next + }) +} + +async function loadBackgroundProcesses(instanceId: string) { + const response = await serverApi.listBackgroundProcesses(instanceId) + setProcesses(instanceId, response.processes) +} + +function getBackgroundProcesses(instanceId: string): BackgroundProcess[] { + return backgroundProcesses().get(instanceId) ?? [] +} + +sseManager.onBackgroundProcessUpdated = (instanceId, event) => { + const process = event.properties?.process + if (!process) return + updateProcess(instanceId, process) +} + +sseManager.onBackgroundProcessRemoved = (instanceId, event) => { + const processId = event.properties?.processId + if (!processId) return + removeProcess(instanceId, processId) +} + +export { + backgroundProcesses, + getBackgroundProcesses, + loadBackgroundProcesses, + removeProcess as removeBackgroundProcess, + updateProcess as updateBackgroundProcess, +}