From e947691aaed107c385ace7a74626d0423fb9b004 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 24 Dec 2025 01:07:56 +0000 Subject: [PATCH] Consolidate plugins under CodeNomad entry --- .../plugin/background-process.ts | 157 -------- packages/opencode-config/plugin/codenomad.ts | 5 + packages/opencode-config/plugin/hello.js | 18 - .../plugin/lib/background-process.ts | 160 ++++++++ .../plugin/lib/legacy/backgroundShell.ts | 341 ------------------ 5 files changed, 165 insertions(+), 516 deletions(-) delete mode 100644 packages/opencode-config/plugin/background-process.ts delete mode 100644 packages/opencode-config/plugin/hello.js create mode 100644 packages/opencode-config/plugin/lib/background-process.ts delete mode 100644 packages/opencode-config/plugin/lib/legacy/backgroundShell.ts diff --git a/packages/opencode-config/plugin/background-process.ts b/packages/opencode-config/plugin/background-process.ts deleted file mode 100644 index e50245cb..00000000 --- a/packages/opencode-config/plugin/background-process.ts +++ /dev/null @@ -1,157 +0,0 @@ -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/codenomad.ts b/packages/opencode-config/plugin/codenomad.ts index 166cb256..4ba2eaba 100644 --- a/packages/opencode-config/plugin/codenomad.ts +++ b/packages/opencode-config/plugin/codenomad.ts @@ -1,8 +1,10 @@ import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" +import { createBackgroundProcessTools } from "./lib/background-process" export async function CodeNomadPlugin() { const config = getCodeNomadConfig() const client = createCodeNomadClient(config) + const backgroundProcessTools = createBackgroundProcessTools(config) await client.startEvents((event) => { if (event.type === "codenomad.ping") { @@ -17,6 +19,9 @@ export async function CodeNomadPlugin() { }) return { + tool: { + ...backgroundProcessTools, + }, async event(input: { event: any }) { const opencodeEvent = input?.event if (!opencodeEvent || typeof opencodeEvent !== "object") return diff --git a/packages/opencode-config/plugin/hello.js b/packages/opencode-config/plugin/hello.js deleted file mode 100644 index f37564d5..00000000 --- a/packages/opencode-config/plugin/hello.js +++ /dev/null @@ -1,18 +0,0 @@ -import { tool } from "@opencode-ai/plugin/tool" - -export async function HelloPlugin() { - return { - tool: { - hello: tool({ - description: "Return a friendly greeting", - args: { - name: tool.schema.string().optional().describe("Name to greet"), - }, - async execute(args) { - const target = args.name?.trim() || "CodeNomad" - return `Hello, ${target}!` - }, - }), - }, - } -} diff --git a/packages/opencode-config/plugin/lib/background-process.ts b/packages/opencode-config/plugin/lib/background-process.ts new file mode 100644 index 00000000..b8d4cec7 --- /dev/null +++ b/packages/opencode-config/plugin/lib/background-process.ts @@ -0,0 +1,160 @@ +import { tool } from "@opencode-ai/plugin/tool" + +type BackgroundProcess = { + id: string + title: string + command: string + status: "running" | "stopped" | "error" + startedAt: string + stoppedAt?: string + exitCode?: number + outputSizeBytes?: number +} + +type CodeNomadConfig = { + instanceId: string + baseUrl: string +} + +export function createBackgroundProcessTools(config: CodeNomadConfig) { + 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 { + 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 deleted file mode 100644 index 815f95d1..00000000 --- a/packages/opencode-config/plugin/lib/legacy/backgroundShell.ts +++ /dev/null @@ -1,341 +0,0 @@ -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